「Linux 网络编程」基于多进程的并发服务器
0 前言
在通过使用 Linux C API 实现一个多进程并发服务器之前,需要掌握以下知识:
-
如何通过系统调用
fork()
创建子进程:「Linux 系统编程」fork() 创建子进程 -
什么是僵尸进程?如何使用
wait()
和waitpid()
回收子进程:「Linux 系统编程」孤儿进程、僵尸进程、wait 和 waitpid 子进程回收 -
进程间常用的通信方式:「Linux 系统编程」进程间通信方式、管道基本使用方法
-
信号进行进程间通信,信号处理函数
signal
、sigaction
的使用:「Linux 系统编程」信号处理函数 signal、sigaction
1 为什么需要并发服务器
在 「Linux 网络编程」Socket 构建 TCP 本地通信 中,实现了一个基本的单进程顺序处理的服务器端。但是,传统的单进程顺序处理服务器端在面对多个客户端连接时,似乎就不是那么高效了😣。
试想一下,现在有 100 个用户通过客户端请求连接服务器,处理每个用户的服务需要 1 秒的时间。第 1 个连接请求的受理时间很快,受理时间为 0 秒。然而,后续每个用户的请求都需要等到前面的用户结束服务才能受理,也就是说,第 50 个请求的受理时间为 50 秒,第 88 个请求的受理时间为 88 秒。这对于后来的用户来说,实在是漫长的等待。
因此,不如考虑以下提供服务的方式:
所有连接请求的受理时间不超过 1 秒,平均的服务时间为 2 ~ 3 秒。
即便服务的时间可能会延长,但是这显然能够向大量同时发起连接请求的客户端提供服务。在网络编程中,数据传输的 I/O 操作过程中,CPU 通常处于空闲状态,在此期间有效地处理多个客户端的连接请求并提供服务,无疑是一种高效利用 CPU 资源的方式。
比较具有代表性的并发服务器实现方式有:
- 多进程并发服务器:通过创建多个子进程,多进程并发地处理请求和提供服务。
- 多路复用服务器:通过捆绑并统一管理 I/O 对象提供服务。
- 多线程服务器:通过生成与客户端等量地线程提供服务。
2 多进程并发服务器的优势
2.1 单进程的局限性
在单进程服务器模型中:
1 |
|
关键问题在于,处理一个请求时,其他客户端必须等待。如果后续连接数显著增加,响应的时间可想而知,会变得非常大。
除此以外,一个请求处理失败可能影响整个服务,容错性有待提高。
2.2 多进程的优势
在多进程服务器模型中:
1 |
|
核心优势:
- 并发:每个客户端由独立进程服务
- 故障隔离:单个进程崩溃不影响整体服务
- 资源隔离:每个进程有独立的内存空间
3 多进程并发服务器的实现
3.1 基本架构
1 |
|
3.2 服务器的初始化
1 |
|
上面的代码实现了 服务器的初始化。不过上面的代码省去了一些错误处理,后续完整实现还是要加上这些错误处理。
其中,setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt))
是网络编程中很重要的一个调用,可以实现服务器的快速重启。
「Linux 网络编程」TCP 内部工作原理_ 以客户端到服务器端发送请求为例介绍了 TCP 的三次握手和四次挥手。上述 SO_REUSEADDR
调用主要是为了应对服务器端到客户端(服务器端主动断开连接)时出现的 TIME_WAIT 问题。
当主动发起断开的服务器端处于 TIME_WAIT 状态下时,相应的端口处于正在使用的状态。通过 netstat
查看 TIME_WAIT 可能出现类似如下的结果:
1 |
|
那么为什么需要 TIME_WAIT:
-
可靠的连接终止
确保最后的ACK丢失时,可以重传 FIN。等待时间:2 * MSL (Maximum Segment Lifetime),通常为60秒
-
防止旧连接的重复数据包干扰新连接
确保所有旧连接的数据包都在网络中消失。
基于上述原因,TIME_WAIT 还是很重要的,但是我们想要实现尽快重启服务器,以快速提供服务。
解决方案就是使用 SO_REUSEADDR
,在套接字的设置选项中更改为 SO_REUSEADDR
的状态,这样就可以使得服务器快速重启,避免服务器重启绑定同一端口出现失败。
1 |
|
3.3 进程管理与信号处理
1 |
|
上述代码主要的作用是实现服务器的优雅退出 以及 子进程回收(避免僵尸进程)。主要采用 sigaction
信号处理函数实现这一部分功能。
在子进程回收的 sa_handler 中特别地使用了 SIG_IGN
,然后注册函数 sigaction(SIGCHLD, &sa, NULL);
,这样可以直接忽略 SIGCHLD 信号,不会发生这个信号给父进程。结合 SA_NOCLDWAIT
使用:
- 子进程退出时 不会变成僵尸进程
- 内核立即自动回收子进程资源
- 不会发送SIGCHLD信号 给父进程
- 但是,父进程无法获取子进程的退出状态
另外,SA_RESTART
是为了:系统调用被该信号中断,让内核 自动重启被中断的系统调用(如 read
, write
)。
当然,也可以实现能够获取子进程退出状态的回收子进程方式,主要方法就是循环调用 waitpid()
回收子进程:
1 |
|
3.4 客户端请求处理
1 |
|
上述代码实现的主要是 服务端处理客户端请求的功能。这和以前实现的服务器端没有什么太大差别 「Linux 网络编程」Socket 构建 TCP 本地通信。
3.5 主服务循环
1 |
|
上述代码主要是在主函数中调用之前的设置信号处理函数、初始化服务端套接字等操作,然后父进程创建子进程处理客户端请求。
在父进程创建一个子进程时,调用 fork()
函数时复制了父进程的所有资源,但是并不会复制套接字。
套接字并非进程所有,而是属于操作系统的。只是 进程拥有指向相应套接字的文件描述符。进程 close()
文件描述符减少引用计数,当引用为 0 时才会真正关闭。
想象一下,文件描述符就像是遥控器,套接字对象就像是电视机。fork()复制了遥控器,但电视机只有一台。多个遥控器可以控制同一台电视机,只有当所有遥控器都扔掉后,电视机才会被关闭。
在上述代码中,子进程中需要关闭设置服务器端的套接字文件描述符,而在父进程中需要关闭给提供客户端服务的套接字文件描述符。为什么要这么做呢?
场景 | 不关闭的后果 | 正确做法 |
---|---|---|
父进程不关闭客户端socket | 文件描述符泄漏,连接无法释放 | close(client_fd) |
子进程不关闭服务器socket | 监听端口无法释放,阻止服务器重启 | close(server_fd) |
fork失败时不关闭socket | 资源泄漏 | 错误处理中也要close() |
4 多进程并发服务器测试
4.1 完整多进程并发服务器代码
结合 3 多进程并发服务器的实现 中的各个模块,可以得到如下 多进程并发服务器 的完整代码:
1 |
|
4.2 测试客户端代码
1 |
|
4.3 运行与测试
编译和运行:
1 |
|
服务端输出:
1 |
|
客户端 1 输出:
1 |
|
客户端 2 输出:
1 |
|
客户端 3 输出:
1 |
|