进程池

进程池

进程池的实现

父子进程创建

父进程处理网络连接

本地套接字

在这里domain必须填写AF_LOCAL,type可以选择流式数据还是消息数据,protocol一般填0表示不需要任何额外的协议,sv这个参数和pipe的参数一样,是一个长度为2的整型数据,用来存储管道两端的文件描述符(值得注意的是,sv[0]sv[1]没有任何的区别)。一般socketpair之后会配合fork函数一起使用,从而实现父子进程之间的通信。从数据传递使用上面来看,本地套接字和网络套接字是完全一致的,但是本地套接字的效率更高,因为它在拷贝数据的时候不需要处理协议相关内容。

父子进程共享文件描述符

父进程会监听特定某个IP:PORT,如果有某个客户端连接之后,子进程需要能够连上accept得到的已连接套接字的文件描述符,这样子进程才能和客户端进行通信。这种文件描述符的传递不是简单地传输一个整型数字就行了,而是需要让父子进程共享一个套接字文件对象。

但是这里会遇到麻烦,因为accept调用是在fork之后的,所以父子进程之间并不是天然地共享文件对象。倘若想要在父子进程之间共享acccept调用返回的已连接套接字,需要采用一些特别的手段:一方面,父子进程之间需要使用本地套接字来通信数据。另一方面需要使用sendmsgrecvmsg函数来传递数据。

使用sendmsgrecvmsg的时候附加一个消息头部,即一个struct msghdr类型的结构体。

首先,需要将要传递的内容存储入msg_iov当中,在这里需要注意的是,元素类型为struct iovec的数组可以存储一组离散的消息,只需要将每个消息的起始地址和本消息的长度存入数组元素中即可。(使用writevreadv可以直接读写一组离散的消息)

接下来,需要将文件描述符的信息存入控制字段msg_control中,这个我们需要存储一个地址值,该地址指向了一个struct cmsghdr类型的控制信息。如果存在多个控制信息,会构成一个控制信息序列,规范要求使用者绝不能直接操作控制信息序列,而是需要用一系列的cmsg宏来间接操作。CMSG_FIRSTHDR用来获取序列中的第一个控制信息(CMSG_NXTHDR获取下一个),CMSG_DATA宏用来设置控制信息的具体数据的地址;CMSG_LEN宏用来设置具体数据占据内存空间的大小。

为了传递文件描述符,需要将结构体中的cmsg_level字段设置为SOL_SOCKET,而 cmsg_type字段需要设置为SCM_RIGHTS,再将数据部分设置为文件描述符。这样,该文件描述符所指的文件对象就可以传递到另一个进程了。

要特别注意的是,传递的文件描述符在数值上完全可能是不相等的,但是它们对应的文件对象确实是同一个,自然文件读写偏移量也是共享的,和之前使用dup或者是先打开文件再fork的情况是一致的。

至此,我们就可以实现一个进程池的服务端了:

文件的传输

这种写法会引入一个非常严重的问题,服务端在接收文件名,实际上并不知道有多长,所以它会试图把网络缓冲区的所有内容都读取出来,但是send底层基于的协议是TCP协议——这是一种流式协议。这样的情况下,服务端没办法区分到底是哪些部分是文件名而哪些部分是文件内容。完全可能会出现服务端把文件名和文件内容混杂在一起的情况,这种就是所谓的"粘包"问题。

所以我们要做的事情是在应用层上构建一个私有协议,这个协议的目的是规定TCP发送和接收的实际长度从而确定单个消息的边界。

进程池1.0

客户端

服务端

进程池的其他功能

进度条显示

零拷贝、sendfile和splice

目前我们传输文件的时候是采用readsend来组合完成,这种当中的数据流向是怎么样的呢?首先打开一个普通文件,数据会从磁盘通过DMA设备传输到内存,即文件对象当中的内核缓冲区部分,然后调用read数据会从内核缓冲区拷贝到一个用户态的buf上面(buf是read函数的参数),接下来调用send,就将数据拷贝到了网络发送缓存区,最终实现了文件传输。但是实际上这里涉及了大量的不必要的拷贝操作。

如何减少从内核文件缓冲区到用户态空间的拷贝呢?解决方案就是使用mmap系统调用直接建立文件和用户态空间buf的映射。这样的话数据就减少了一次拷贝。在非常多的场景下都会使用mmap来减少拷贝次数,典型的就是使用图形的应用去操作显卡设备的显存。除此以外,这种传输方式也可以减少由于系统调用导致的CPU用户态和内核态的切换次数。

使用mmap系统调用只能减少数据从磁盘文件的文件对象到用户态空间的拷贝,但是依然无法避免从用户态到内核已连接套接字的拷贝(因为网络设备文件对象不支持mmap)。sendfile系统调用可以解决这个问题,它可以使数据直接在内核中传递而不需要经过用户态空间,调用sendfile系统调用可以直接将磁盘文件的文件对象的数据直接传递给已连接套接字文件对象,从而直接发送到网卡设备之上(在内核的底层实现中,实际上是让内核磁盘文件缓冲区和网络缓冲区对应同一片物理内存)。

使用sendfile的时候要特别注意,out_fd一般只能填写网络套接字的描述符,表示写入的文件描述符,in_fd一般是一个磁盘文件,表示读取的文件描述符。从上述的需求可以得知,sendfile只能用于发送文件方的零拷贝实现,无法用于接收方,并且发送文件的大小上限通常是2GB。

考虑到sendfile只能将数据从磁盘文件发送到网络设备中,那么接收方如何在避免使用mmap的情况下使用零拷贝技术呢?一种方式就是采用管道配合splice的做法。splice系统调用可以直接将数据从内核管道文件缓冲区发送到另一个内核文件缓冲区,也可以反之,将一个内核文件缓冲区的数据直接发送到内核管道缓冲区中。所以只需要在内核创建一个匿名管道,这个管道用于本进程中,在磁盘文件和网络文件之间无拷贝地传递数据。

进程池的退出

进程池的简单退出

进程池的简单退出要实现功能很简单,就是让父进程收到信号之后,再给每个子进程发送信号使其终止,这种实现方案只需要让父进程在一个目标信号(通常是10信号SIGUSR1)的过程给目标子进程发送信号即可。

在实现的过程需要注意的是signal函数和fork函数之间调用顺序,因为父进程会修改默认递送行为,而子进程会执行默认行为,所以fork应该要在signal的之后调用。

使用管道通知工作进程终止

采用信号就不可避免要使用全局变量,因为信号处理函数当中只能存储有限的信息,有没有办法避免全局的进程数量和进程数组呢?一种解决方案就是采取“异步拉起同步”的策略:虽然还是需要创建一个管道全局变量,但是该管道只用于处理进程池退出,不涉及其他的进程属性。这个管道的读端需要使用IO多路复用机制管理起来,而当信号产生之后,主进程递送信号的时候会往管道中写入数据,此时可以依靠epoll的就绪事件,在事件处理中来完成退出的逻辑。

进程池的优雅退出

上述的退出机制存在一个问题,就是即使工作进程正在传输文件中,父进程也会通过信号将其终止。如何实现进程池在退出的时候,子进程要完成传输文件的工作之后才能退出呢?

一种典型的方案是使用sigprocmask在文件传输的过程中设置信号屏蔽字,这样可以实现上述的机制。

另一种方案就是调整sendFd的设计,每个工作进程在传输完文件之后总是循环地继续下一个事件,而在每个事件处理的开始,工作进程总是会调用recvFd来使自己处于阻塞状态直到有事件到达。我们可以对进程池的终止作一些调整:用户发送信号给父进程表明将要退出进程池;随后父进程通过sendFd给所有的工作进程发送终止的信息,工作进程在完成了一次工作任务了之后就会recvFd收到进程池终止的信息,然后工作进程就可以主动退出;随着所有的工作进程终止,父进程亦随后终止,整个进程池就终止了。

线程池

线程池的实现

用进程池的思路来解决并发连接是一种经典的基于事件驱动模型的解决方案,但是由于进程天生具有隔离性,导致进程之间通信十分困难,一种优化的思路就是用线程来取代进程,即所谓的线程池。

由于多线程是共享地址空间的,所以主线程和工作线程天然地通过共享文件描述符数值的形式共享网络文件对象,但是这种共享也会带来麻烦:每当有客户端发起请求时,主线程会分配一个空闲的工作线程完成任务,而任务正是在多个线程之间共享的资源,所以需要采用一定的互斥和同步的机制来避免竞争。

我们可以将任务设计成一个队列,任务队列就成为多个线程同时访问的共享资源,此时问题就转化成了一个典型的生产者-消费者问题:任务队列中的任务就是商品,主线程是生产者,每当有连接到来的时候,就将一个任务放入任务队列,即生产商品,而各个工作线程就是消费者,每当队列中任务到来的时候,就负责取出任务并执行。

下面是线程池的基本设计方案:

线程池

接下来,主线程需要accept客户端的连接并且需要将任务加入到任务队列。(目前会引发主线程阻塞的行为只有accept,但是为了可维护性,即后续的需求可能需要主线程管理更多的文件描述符,所以我们使用epoll将网络文件加入监听)。一旦有新的客户端连接,那么主线程就会将新的任务加入任务队列,并且使用条件变量通知子线程。(如果没有空闲的子线程处于等待状态,这个任务会被直接丢弃)

子线程在启动的时候,会使用条件变量使自己处于阻塞状态,一旦条件满足之后,就立即从任务队列中取出任务并且处理该事件。

线程池的退出

简单退出

直接使用上述代码会存在一个问题,那就是只能关闭掉一个子线程,这里的原因其实比较简单pthread_cond_wait是一个取消点,所以收到了取消之后,线程会唤醒并终止,然而由于条件变量的设计,所以线程终止的时候它是持有锁的,这就导致死锁。这种死锁的解决方案就是引入资源清理机制,在加锁行为执行的时候立刻将清理行为压入资源清理栈当中。

优雅退出

如果使用pthread_cancel,由于读写文件的函数是取消点,那么正在工作线程也会被终止,从而导致正在执行的下载任务无法完成。如何实现线程池的优雅退出呢?一种解决方案就是不使用pthread_cancel,而是让每个工作线程在事件循环开始的时候,检查一下线程池是否处于终止的状态,这样子线程就会等待当前任务执行完成了之后才会终止。