今天我们聊一下高并发下的网络 IO 模型
高并发即我们所说的 C10K(一个 server 服务 1w 个 client),C10M,写出高并发的程序相信是每个后端程序员的追求,高并发架构其实有一些很通用的架构设计,如无锁化,缓存等,今天我们主要研究下高并发下的网络 IO 模型设计,我们知道不管是 Nginx,还是 Redis,Kafka,RocketMQ 等中间件,都能轻松支持非常高的 QPS,其实它们背后的网络 IO 模型设计理念都是一致的,所以了解这一块对我们了解设计出高并发的网络 IO 框架具体重要意义,本文将会从以下几个方面来循序渐近地向大家介绍如何设计出一个高并发的网络 IO 框架
- 传统网络 IO 模型的缺陷
- 针对传统网络 IO 模型缺陷的改进
- 多线程/多进程
- 阻塞改为非阻塞
- IO 多路复用
- Reactor 的几种模型介绍
传统网络 IO 模型的缺陷
我们首先来看下传统网络 IO 模型有哪些缺陷,主要看它们的阻塞点有哪些。我们用一张图来看下客户端和服务端的基于 TCP 的通信流程
服务端的伪代码如下
listenSocket = socket(); //调用socket系统调用创建一个主动套接字
bind(listenSocket); //绑定地址和端口
listen(listenSocket); //将默认的主动套接字转换为服务器使用的被动套接字,也就是监听套接字
while (1) { //循环监听是否有客户端连接请求到来
connSocket = accept(listenSocket); //接受客户端连接
recv(connsocket); //从客户端读取数据,只能同时处理一个客户端
send(connsocket); //给客户端返回数据,只能同时处理一个客户端
}
可以看到,主要的通信流程如下
- server 创建监听 socket 后,执行 bind() 绑定 IP 和端口,然后调用 listen() 监听,代表 server 已经准备好接收请求了,listen 的主要作用其实是初始化半连接和全连接队列大小
- server 准备好后,client 也创建 socket ,然后执行 connect 向 server 发起连接请求,这一步会被阻塞,需要等待三次握手完成,第一次握手完成,服务端会创建 socket(这个 socket 是连接 socket,注意不要和第一步的监听 socket 搞混了),将其放入半连接队列中,第三次握手完成,系统会把 socket 从半连接队列摘下放入全连接队列中,然后 accept 会将其从全连接队列中摘下,之后此 socket 就可以与客户端 socket 正常通信了,默认情况下如果全连接队列里没有 socket,则 accept 会阻塞等待三次握手完成
经过三次握手后 client 和 server 就可以基于 socket 进行正常的进程通信了(即调用 write 发送写请求,调用 read 执行读请求),但需要注意的是 read,write 也很可能会被阻塞,需要满足一定的条件读写才会成功返回,在 LInux 中一切皆文件,socket 也不例外,每个打开的文件都有读写缓冲区,如下图所示
对文件执行 read(),write() 的具体流程如下
- 当执行 read() 时,会从内核读缓冲区中读取数据,如果缓冲区中没有数据,则会阻塞等待,等数据到达后,会通过 DMA 拷贝将数据拷贝到内核读缓冲区中,然后会唤醒用户线程将数据从内核读缓冲区拷贝到应用缓冲区中
- 当执行 write() 时,会将数据从应用缓冲区拷贝到内核写缓冲区,然后再通过 DMA 拷贝将数据从写缓冲区发送到设备上传输出去,如果写缓冲区满,则 write 会阻塞等待写缓冲区可写
经过以上分析,我们可以看到传统的 socket 通信会阻塞在 connect,accept,read/write 这几个操作上,这样的话如果 server 是单进程/线程的话,只要 server 阻塞,就不能再接收其他 client 的处理了,由此可知传统的 socket 无法支持 C10K
针对传统网络 IO 模型缺陷的改进
接下来我们来看看针对传统 IO 模型缺陷的改进,主要有两种
- 多进程/线程模型
- IO 多路程复用
多进程/线程模型
如果 server 是单进程,阻塞显然会导致 server 无法再处理其他 client 请求了,那我们试试把 server 改成多进程的?只要父进程 accept 了 socket ,就 fork 一个子进程,把这个 socket 交给子进程处理,这样就算子进程阻塞了,也不影响父进程继续监听和其他子进程处理连接
程序伪代码如下
while(1) {
connfd = accept(listenfd); // 阻塞建立连接
// fork 创建一个新进程
if (fork() == 0) {
// accept 后子进程开始工作
doWork(connfd);
}
}
void doWork(connfd) {
int n = read(connfd, buf); // 阻塞读数据
doSomeThing(buf); // 利用读到的数据做些什么
close(connfd); // 关闭连接,循环等待下一个连接
}
通过这种方式确实解决了单进程 server 阻塞无法处理其他 client 请求的问题,但众所周知 fork 创建子进程是非常耗时的,包括页表的复制,进程切换时页表的切换等都非常耗时,每来一个请求就创建一个进程显然是无法接受的
为了节省进程创建的开销,于是有人提出把多进程改成多线程,创建线程(使用 pthread_create)的开销确实小了很多,但同样的,线程与进程一样,都需要占用堆栈等资源,而且碰到阻塞,唤醒等都涉及到用户态,内核态的切换,这些都极大地消耗了性能
由此可知采用多进程/线程的方式并不可取
画外音: 在 Linux 下进程和线程都是用统一的 task_struct 表示,区别不大,所以下文描述不管是进程还是线程区别都不大
阻塞改为非阻塞
既然多进程/多线程的方式并不可取,那能否将进程的阻塞操作(connect,accept,read/write)改为非阻塞呢,这样只要调用这些操作,如果相应的事件未准备好,就立马返回 EWOULDBLOCK 或 EAGAIN 错误,此时进程就不会被阻塞了,使用 fcntl 可以可以将 socket 设置为非阻塞,以 read 为例伪代码如下
connfd = accept(listenfd);
fcntl(connfd, F_SETFL, O_NONBLOCK);
// 此时 connfd 变为非阻塞,如果数据未就绪,read 会立即返回
int n = read(connfd, buffer) != SUCCESS;
read 的非阻塞操作流程图如下
非阻塞read
这样的话调用 read 就不会阻塞等待而会马上返回了,也就实现了非阻塞的效果,不过需要注意的,我们这里说的非阻塞并非严格意义上的非阻塞,这里的非阻塞只是针对网卡数据拷贝到内核缓冲区这一段,如果数据就绪后,再执行 read 此时依然是阻塞的,此时用户进程会占用 CPU 去把数据从内核缓冲区拷贝到用户缓冲区中,可以看到这种模式是同步非阻塞的,这里我们简单解释下阻塞/非阻塞,同步/非同步的概念
- 阻塞/非阻塞指的是在数据从网卡拷贝到内核缓冲区期间,进程能不能动