你好,我是坤哥
今天我们聊一下高并发下的网络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);}}voiddoWork(connfd){intn=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会立即返回intn=read(connfd,buffer)!=SUCCESS;
read的非阻塞操作流程图如下
非阻塞read
这样的话调用read就不会阻塞等待而会马上返回了,也就实现了非阻塞的效果,不过需要注意的,我们这里说的非阻塞并非严格意义上的非阻塞,这里的非阻塞只是针对网卡数据拷贝到内核缓冲区这一段,如果数据就绪后,再执行read此时依然是阻塞的,此时用户进程会占用CPU去把数据从内核缓冲区拷贝到用户缓冲区中,可以看到这种模式是同步非阻塞的,这里我们简单解释下阻塞/非阻塞,同步/非同步的概念
阻塞/非阻塞指的是在数据从网卡拷贝到内核缓冲区期间,进程能不能动,如果能动,就是非阻塞的,不能动就是阻塞的
同步/非同步指的是数据就绪后是否需要用户进程亲自调用read来搬运数据(将数据从内核空间拷贝到用户空间),如果需要,则是同步,如果不需要则是非同步(即异步),异步I/O示意图如下:异步IO
异步IO执行流程如下:进程发起I/O请求后,让内核在整个操作处理完后再通知进程,这整个操作包括网卡拷贝数据到内核缓冲区,将数据从内核缓冲区拷贝到用户缓冲区这两个阶段,内核在处理数据期间(从无数据到拷贝完成),应用进程是可以继续执行其他逻辑的,异步编程需要操作系统支持,目前只有windows完美支持,Linux暂不支持。可以看出异步I/O才是真正意义上的非阻塞操作,因为数据从内核缓冲区拷贝到用户缓冲区这一步不需要用户进程来操作,而是由内核代劳了
我们以一个案例来总结下阻塞/非阻塞,同步/异步:当你去餐馆点餐时,如果在厨师做菜期间,你啥也不能干,那就是阻塞,如果在此期间你可以玩手机,喝喝茶,能动,那就是非阻塞,如果厨师做好了菜,你需要亲自去拿,那就是同步,如果厨师做好了,菜由服务员直接送到你的餐桌,那就是非同步(异步)
现在回过头来看将阻塞转成非阻塞是否满足了我们的需求呢?看起来进程确实可以动了,但进程需要不断地以轮询数据的形式调用accept,read/write这此操作来询问内核数据是否就绪了,这些都是系统调用,对性能的消耗很大,而且会持续占用CPU,导致CPU负载很高,远不如等数据就绪好了再通知进程去取更高效。这就好比,厨师做菜期间,你不断地去问菜做好了没有,显然没有意义,更高效的方式无疑是等厨师菜做好了主动通知你去取
IO多路复用
经过前面的分析我们可以得出两个结论
使用多进程/多线程IO模型是不可行的,这一步可以优化为单线程
应该等数据就绪好了之后再通知用户进程去读取数据,而不是做毫无意义的轮询,注意这里的数据就绪不光是指前文所述的read的数据已就绪,而是泛指accept,read/write这三个事件的数据都已就绪
于是IO多路复用模型诞生了,它是指用一个进程来监听listensocket(监听socket)的连接建立事件,connectsocket(已连接socket)的读写事件(读写),一旦数据就绪,内核就会唤醒用户进程去处理这些socket的相应的事件
IO多路复用
这里简单介绍一下fd(文件描述符),以便大家更好地了解之后IO多路复用中出现的fd集合等概念
文件系统简介
我们知道在Linux中无论是文件,socket,还是管道,设备等,一切皆文件,Linux抽象出了一个VFS(virtualfilesystem)层,屏蔽了所有的具体的文件,VFS提供了统一的接口给上层调用,这样应用层只与VFS打交道,极大地方便了用户的开发,仔细对比你会发现,这和Java中的面向接口编程很类似
通过open(),socket()创建文件后,都有一个fd(文件描述符)与之对应,对于每一个进程,都有有一个文件描述符列表(FileDiscriptorTable)来记录其打开的文件,这个列表的每一项都指向其背后的具体文件,而每一项对应的数组下标就是fd,对于用户而言,可以认为fd代表其背后指向的文件
fd的值从0开始,其中0,1,2是固定的,分别指向标准输入(指向键盘),标准输出/标准错误(指向显示器),之后每打开一个文件,fd都会从3开始递增,但需要注意的是fd并不一定都是递增的,如果关闭了文件,之前的fd是可以被回收利用的
IO多路复用其实也就是用一个进程来监听多个fd的数据事件,用户可以把自己感兴趣的fd及对应感兴趣的事件(accept,read/write)传给内核,然后内核就会检测fd,一旦某个socket有事件了,内核可以唤醒用户进程来处理
那么怎样才能知道某个fd是否有事件呢,一种很容易想到的做法是搞个轮询,每次调用一下read(fd),让内核告知是否数据已就绪,但是这样的话如果有n个感兴趣的fd就会有n次read系统调用,开销很大,显然不可接受
所以使用IO多路复用监听fd的事件可行,但必须解决以下三个涉及到性能瓶颈的点
如何高效将用户感兴趣的fd和事件传给内核
某个socket数据就绪后,内核如何高效通知用户进程进行处理
用户进程如何高效处理事件
前面两步的处理目前有select,poll,epoll三种IO多路事件模型,我们一起来看看,看完你就会知道为啥epoll的性能是如此高效了
select
我们先来看下select函数的定义
返回:若有就绪描述符则为其数目,若超时则为0,若出错则为-1intselect(intmaxfd,fd_set*readset,fd_set*writeset,fd_set*exceptset,conststructtimeval*timeout);
maxfd是待测试的描述符基数,为待测试的最大描述符加1,readset,writeset,exceptset分别为读描述符集合,写描述符集合,异常描述符集合,这三个分别通知内核,在哪些描述描述符上检测数据可以读,可写,有异常事件发生,timeout可以设置阻塞时间,如果为null代表一直阻塞
这里需要说明一下,为啥maxfd为待测试的描述符加1呢,主要是因为数组的下标是从0开始的,假设进程新建了一个listenfd,它的fd为3,那么代表它有4个感兴趣的fd(每个进程有固定的fd=0,1,2这三个描述符),由此可知maxfd=3+1=4
接下来我们来看看读,写,异常集合是怎么回事,如何设置针对fd的感兴趣事件呢,其实事件集合是采用了一种位结构(bitset)的方式,比如现在假设我们对标准输入(fd=0),listenfd(fd=3)的读事件感兴趣,那么就可以在readset对应的位上置1
画外音:使用FD_SET可将相应位置置1,如FD_SET(listenfd,readset)
如下
即readset为{1,0,0,1},在调用select后会将readset传给内核,如果内核发现listenfd有连接已就绪的事件,则内核也会在将相应位置置1(其他无就绪事件的fd对应的位置为0)然后会回传给用户线程,此时的readset如下,即{1,0,0,0}
于是进程就可以根据readset相应位置是否是1(用FD_ISSET(i,read_set)来判断)来判断读事件是否就绪了
需要注意的是由于accept的socket会越来越多,maxfd和事件set都需要及时调整(比如新accept一个已连接的socket,maxfd可能会变,另外也需要将其加入到读写描述符集合中以让内核监听其读写事件)
可以看到select将感兴趣的事件集合存在一个数组里,然后一次性将数组拷贝给了内核,这样n次系统调用就转化为了一次,然后用户进程会阻塞在select系统调用上,由内核不断循环遍历,如果遍历后发现某些socket有事件(accept或read/write准备好了),就会唤醒进程,并且会把数据已就绪的socket数量传给用户进程,动图如下
select图解,图片来自《低并发编程》
select的伪代码如下
intlisten_fd,conn_fd;//监听套接字和已连接套接字的变量listen_fd=socket()//创建套接字bind(listen_fd)//绑定套接字listen(listen_fd)//在套接字上进行监听,将套接字转为监听套接字fd_setmaster_rset;//被监听的描述符集合,