进程池和线程池

1, 进程池

假设: 我们结合学过的文件操作、网络通信、以及进程和线程的知识, 实现一个基本的文件下载服务器模型, 我们需要做那些准备工作, 或者说我们怎么设计整个数据通信逻辑。

设计一个服务器中, 在大多数情况下, 都会有很多连接的频繁接入和断开, 如果在通信模型中, 我们让一个进程既处理连接接入,又处理业务逻辑, 这样设计在当下应用领域无疑是很低效的。(它既无法做到有效解耦, 增加代码书写的麻烦, 增加并行逻辑设计困难, 又无法有效利用多核性能, 伴随性能瓶颈)

一个良好的架构需要满足许多侧面的要求,其中最基本的要求是可维护性和性能:

  • 可维护性: 是指应用程序对开发者应该足够友好,开发和维护的程序员可以很快速的就能理解程序架构并进行后续开发。为了提供可维护性,项目各个部分的功能应当彼此分离。

  • 性能: 是指应用程序应当充分利用操作系统资源,并且减少不要资源消耗。在多进程程序中, 一种非常浪费资源的操作就是创建和销毁进程。

所以我们无论从代码开发难易上还是性能提升上, 都有必要利用多进程或者多线程来实现业务逻辑任务管控逻辑分离。

我么可以维护一个主进程只负责接收用户请求, 并把接收到的用户请求分配到不同进程上来处理。但是如果我们对任务进程不做任何限制和管理, 随着任务的到达开始一个进程处理任务, 任务处理结束之后结束这个进程, 这样处理是极其不好的, 因为进程的频繁创建和销毁是一份很大的软硬件开销。

而借用池化思想可以有效的避免这个问题(很多地方都有池化设计) , 我们维护一个进程的池子, 包含多个进程, 当有任务到来, 把任务交给空闲的进程执行, 当任务执行完毕, 并且没有任务可执行, 让进程休眠。这种池化的思想可以显著减少创建和销毁进程的软硬件开销,进而提高程序的执行效率。

  • 主进程负责接收用户请求, 获得连接的文件描述符, 并且维护所有进程池中进程的状态, 以方便把文件描述符对象交给空闲的池中进程来和客户端直接交互。

  • 进程池中进程, 在被主进程唤醒, 接收到对应的连接文件描述符对象, 按照需求读取磁盘文件, 并把读取的磁盘文件发送给客户端。

  • 客户端建立连接, 接收返回的文件。

进程池和线程池

以上述服务模型为例, 我们把进程池换成线程池, 逻辑上也是相同的, 那么在我们设计的时候, 怎么确定使用进程池设计还是线程池设计。

进程池设计:

  • 每个进程有独立的内存空间, 增加进程间的隔离性。一个进程崩溃不会影响到其它进程。

  • 进程间存在隔离性, 这种解耦促使业务逻辑方便书写。

  • 进程的创建和销毁比线程开销大, 占用的内存空间也比线程大。

  • 上下文的调度切换时间长。

  • 适合并发量低(IO请求数少), 业务复杂(CPU密集), 任务执行事件长的系统设计。

线程池设计:

  • 线程之间共享资源, 隔离新差, 一个线程极容易影响到另一个线程(数据同步和一致性)。

  • 但是隔离性差, 使得线程间通信比进程间通信要更方便。

  • 线程较轻量,创建和销毁的开销较小。

  • 适合并发量高(I/O密集型),内存使用要求高, 业务简单, 可以大量快速、轻量级任务处理的场景。

1.1 第一版

1.1.1 设计逻辑

  • head.h

    • 头文件的引入

    • 函数定义

    • 结构体和类型定义

  • main.c

    • 调用pool初始化进程池; 并让pool启动线程池中线程worker

    • 调用tcpInit初始化socket对IP&端口监听

    • 调用epoll监听客户端连接进来, 当连接进来, 调用pool把连接交给进程池中进程worker处理, 修改进程状态为忙

    • 调用epoll监听和进程池中线程, 等待进程池中线程处理完客户端请求, 通知main, 把其状态改为空闲

  • pool.c

    • 根据main的要求, 启动对应数量的进程worker

    • 把启动的进程及其状态记录到数组中, 以供main监控worker结束任务后, 向main发起的通信(由忙置为闲的通信)

    • 当main有客户端连接过来, 通知pool, pool从空闲的进程中选取一个进程执行和客户端通信

  • worker.c

    • 被pool初始化, 并启动

    • 等待接收main获取的客户端连接

    • 接收到main让pool通知过来的客户端连接, 拿到连接, 与指定的客户端通信, 通信完毕关闭客户端连接

    • 通信结束, 向main发信息, 告诉其自己应该由忙状态更改为闲状态

    • 等待main分发过来新的客户端

  • tcpInit.c

    • 初始化ip和端口的监控

    • 避免大量的tcp连接代码写在main中, 为了解耦而存在

  • epoll.c

    • epoll的操作相关

    • 避免大量epoll添加文件描述符监听代码在main中, 为了解耦而存在

  • localSocket.c

    • 当main监听到一个客户端连接, 需要把这个客户端连接对象交给工作的worker, 这涉及到进程间通信

    • 这个进程间通信不是一个简单的问题, 因为这个进程间通信不是传递字符串,数字等简单的东西,而是要传递一个文件描述符, 而这个传送的文件描述符还要具有共享文件对象的能力

    • 这个地方就要用到一个特殊的功能强大的本地通信socket, 用于main和worker间进行一个可以最终共享文件对象的文件描述符的传递

1.1.2 socketpair

一般我们使用它的目的: 为了在两个进程之间, 传输一个文件对象的描述信息(可以让两个进程共享一个文件对象), 而不是单单只传输一个文件描述符数组的下标.

socketpair()函数用于创建一对互相连接的全双工通信socket。相比较普通的用于网络间不同主机通信的socket; socketpair函数创建的socket主要用于在同一台机器上进程间通信(我们可以称其为本地socket)。 (man socketpair)

  • 全双工通信: (对于socketpair函数构建int sv[2]文件描述符数组)

如果你想通过socketpair函数实现两个本地进程间的文件对象描述符的传输,除了需要socketpair函数创建通信的端点, 还需要借助sendmsg函数和recvmsg函数来实现具体的数据传输。 (man sendmsg) (man recvmsg)

代码示例

1.2.3 Makefile

在一个复杂项目中, 一般情况下我们需要通过多个.c文件, 相互协作, 共同编译出一个可执行文件运行, 那么就需要稍稍修改makefile的书写规则

1.1.4 CODE

Main

pool

worker

localSocket

tcpInit

epoll

client

1.2 第二版

在上面过程中, 我们解决了进程间共享文件对象的问题, 并且实现了服务器的主进程接收客户端连接请求, 并把客户端连接交给进程池中进程具体和客户端进行交互的功能。

假设在上一版本的基础上, 如果客户端的请求是想获得一份在服务端的文件, 我们该把前面向客户端发送简单的字符信息, 变为向客户端传输文件那? 客户端又怎么解决接收文件的问题?

1.2.1 文件的传输

客户端下载文件/服务器向客户端传输文件: 以一个小文件(eg:1000字节)为例

EgCode: 服务器 (修改worker: netToClient -> sendFile)

EgCode: 客户端( client调用downloadFile )

1.2.2 粘包问题

在上面示例中, 当我们先调用send向客户端发送文件名, 之后又调用send向客户端发送从文件读取的数据, 连续两次发送, 而我们发送的数据是没有设定的数据边界的(因为TCP的接收缓冲区将多个发送的数据序列视为连续的字节流), 所以当客户端读取的时候, 有可能一次recv读取了两次发送的内容. 并且把文件名+文件内容作为文件名创建一个文件. 这就是说为的粘包问题. (UDP没有粘包问题, 因为UDP发送数据, 并不会像TCP协议那样在TCP层/传输层对数据进行拆分和重组, UDP的拆分重组行为是IP层/网络层进行的, UDP层/传输层无感知, UDP层/传输层只会觉得每一个UDP报文段都是完整的.)

所以我们需要做的事情就是厘定数据传输的边界: 参考我们前面的管道传输文件的示例, 我们可以进行如下改造.

EgCode : worker

EgCode: client

1.2.3 发送大文件

假设我们发送一个大文件: 代码改造如下

EgCode: worker

EgCode: client

问题:

在上面发送的过程中, 我们会发现, 如果一个文件比较大, 偶尔会在客户端接收文件的时候, 产生接收错误. 这是因为, 我们的信息发送行为并不是由send函数控制(send函数本身只是把要发送的数据, 交给操作系统), 具体什么时候真正发送数据, 是由操作系统决定的. 操作系统, 有可能在发送数据的时候, 某个train只发送了一半, 然后被客户端读取, 在一段时之后, 操作系统发送了另一半train给客户端, 客户端先读取数据长度的时候, 出现错误, 导致最终数据读取错误. 这种不可控的行为我们称之为半包问题.

所以在recv函数中, 提供了关于接收行为的标志位中提供了MSG_WAITALL字段, 用于控制recv读取指定的len长度的数据才返回. 进而解决半包问题.

EgCode: client

问题:

在发送大文件的时候, 客户端有可能在发送的时候提前终止, 这会导致发送端/写端(send)因为抛出SIGPIPE导致进程终止.

EgCode: worker

改进

1.2.4 进度条

如果我们想模仿日常下载文件的时候, 进度条显示的效果. 我们可以在文件传输之前, 先传输文件大小给客户端, 在客户端不断接收文件的时候, 根据已经接收的文件的大小/总文件的大小, 显示进度条.

我们需要用到fstat函数获得一个文件的状态信息 (man fstat)

EgCode: worker

EgCode: client

1.2.5 零拷贝

以上述代码为例, 在数据传输过程中, 我们的服务端需要先从读取文件到内核态, 然后把内核态数据拷贝到用户态, 再从用户态拷贝到内核态让系统发送数据, 如果我们能避免数据从内核态和用户态的来回拷贝, 当需要发送数据的时候, 直接从磁盘读取到的数据, 在内核态直接转给系统发送, 从逻辑上将显著提高数据传输效率. 这就是所谓的零拷贝问题.

mmap

mmap函数用于创建一个新的映射在进程的用户态空间中(分配虚拟的空间未作数据加载)。当我们真正需要使用和访问这个数据的时候, 假设这些数据被socket的send函数调用发送给客户端, 那么内核在执行send发送数据的行为的时候, 是把加载到用户态的文件数据拷贝到内核, 避免了像先read数据那样(先把数据从内核空间拷贝到用户空间), 然后再send的时候(再把用户空间数据拷贝到内核态空间)的两次拷贝, 也就是说这是一次数据拷贝和两次数据拷贝的问题

EgCode: worker

EgCode: client: 版本一, 进度条接收

EgCode: client: 版本二: mmap接收

sendfile

对于sendfile函数(新版的), 它存在的本质意义, 是它能直接在内核空间内传输数据(当socket发送信息到网卡上时, 不再从socket的发送缓冲区发送给网卡, 而是让读取文件的缓冲区发送给网卡)(也就是说: 磁盘文件->内核文件缓冲区->网卡, 不再经过socket的发送缓冲区), 这样当我们需要send发送的数据的时候, 相比较mmap又少了一次拷贝.

EgCode: worker

1.3 第三版

当我们在shell窗口上按下ctrl+c: 这是给当前会话的前台进程组的所有进程, 发送信号

(一个会话: 包含一个前台进程组, 多个后台进程组 )

( 进程组: 一组进程: fork()->产生子进程 -> 和父进程在同一个进程组 )

假设我们通过kill -2 pid: 这种行为仅仅是发送信号给指定的pid进程

1.3.1 一个问题

当我们关闭服务端进程的时候, 我们试图直接ctrl+c可以结束进程, 但仅仅以当前的代码逻辑, 这不是一个良好的退出方式.

EgCode: worker.c

我们会发现在上述逻辑中(可以通过ps -elf查看), 主进程main的关闭, 子进程已经存活

1.3.2 有序退出

假设我们希望主进程main的退出, 也能导致子进程退出, 我们可以修改代码逻辑实现.

我们可以监听信号, 当信号触发, 让main进程向子进程发送关闭进程的信息, 子进程收到信息之后, 关闭子进程, 然后主进程等待子进程退出之后再退出.

EgCode: main.c

EgCode: localSocket.c

EgCode: worker.c

 

回顾:

0, 在公司中工作的一般/标准顺序: 接到需求 -> 明确/讨论需求 -> 设计业务逻辑文档 -> 设计代码逻辑文档 (接口)-> 照着文档实现代码 -> 有bug改bug -> 测试(自测) -> 提交测试(测试人员测) -> 改bug

1, 进程的设计逻辑:

a. 主进程逻辑: 启动子进程, 等待客户端连接

一旦有客户端连接过来 -> accept -> 穿给闲状态的子进程 -> 进程共享文件对象(本地socket: socketpair)

b, 读取任务/客户端连接对象:

给客户端交互 (发文件) -> 大文件(粘包-> 设置边界/模拟一个协议/小火车, 半包/MSG_WAITALL)

读取数据的时候关闭了连接 -> 导致服务器抛出信号SIGPIPE -> 给服务器的send: MSG_NOSIGNAL

子进程向客户端发完数据 -> 通知main进程 -> 由忙状态变为闲状态

2, 优化:

a, 进度条: 为了写代码而优化

b, 复制数据的问题:

mmap: 优化传输, 优化了一次拷贝.

sendfile : 相比较传统read/send, 优化了两次拷贝

(有些地方也称sendfile叫零拷贝 )

c, 有序退出:

不想暴力退出 -> 坏处, 不太好

有序退出 -> 指的是通知进程可以退出, 让进程自己选择合适的事件退出 (给进程处理资源的机会)

捕捉信号-> 通过管道发给main进程-> 通过本地socket发给子进程 -> 子进程选择合适的时机退出 -> 主进程等到所有子进程退出之后, 自己也退出.