网络总概
主要模块
- I/O处理单元
- 处理客户连接
- 逻辑单元
- 业务进程或线程
- 存储单元(可选)
- 本地数据库、文件或缓存
- 请求队列
- 各单元之间通信的抽象:I/O单元接受到请求时,需要通知逻辑单元来处理;多个逻辑单元之间的通信
I/O处理单元
IO模型
阻塞非阻塞与同步异步区别:阻塞非阻塞指的是等待内核数据时的状态是否阻塞(即程序能否向下执行),同步异步是指复制数据的过程是否阻塞(即程序能否向下执行)
-
阻塞I/O
- DMA数据准备时阻塞;内核到用户的数据复制阻塞
- 阻塞IO和非阻塞IO区别:看从内核到用户的数据复制行为,如调用read时,若内核无数据,阻塞IO就原地等待数据到达(数据通过DMA从硬件到内核),非阻塞IO就是直接返回(返回值为EWOULDBLOCK或EAGAIN,此时数据也开始通过DMA进行准备)
-
非阻塞I/O
- DMA数据准备时不阻塞;内核到用户的数据复制阻塞
-
I/O复用
- 属于非阻塞IO,注意的是I/O复用函数本身是阻塞的(不过可以设置超时时间),之所以能提高效率是因为它们具有同时监听多个I/O事件的能力
- 一种I/O通知机制,常用函数为:select、poll、epoll_wait
-
信号驱动I/O
- 内核中无数据时读取不阻塞,当数据准备好时通过SIGIO信号来通知程序,然后再执行read或write操作
- 属于非阻塞IO、同步IO。相比上面的传统非阻塞IO相比,优点就是不用轮询查看数据是否准备好,而是通过信号进行通知
- 因信号驱动要求有另一端主动写入数据,所以socket、pipe、fifo、terminal等文件描述符类型是可以信号驱动IO 的,但是不支持对普通文件使用信号驱动IO
-
异步I/O
-
对异步I/O而言,用户可以直接对I/O执行读写操作,这些操作告诉内核用户读写缓冲区的位置,以及I/O操作完成之后内核通知应用程序的方式。异步I/O的读写操作总是立即返回,而不论I/O是否是阻塞的,因为真正的读写操作已经由内核接管。
-
同步I/O模型要求用户代码自行执行I/O操作(将数据从内核缓冲区读人用户缓冲区,或将数据从用户缓冲区写人内核缓冲区),这个过程中进程是阻塞的;而异步I/O机制则由内核来执行I/O操作(数据在内核缓冲区和用户缓冲区之间的移动是由内核在“后台”完成的),这个过程中进程不阻塞。你可以这样认为,同步I/O向应用程序通知的是I/O就绪事件,而异步I/O向应用程序通知的是I/O完成事件。
-
阻塞、非阻塞、IO复用、信号驱动都是同步IO模型
-
可参考: https://www.cnblogs.com/f-ck-need-u/p/7624733.html
事件处理模式
- Reactor
- 通常用非阻塞同步IO来实现
- Reactor是这样一种模式,它要求主线程(I/O 处理单元)只负责监听文件描述上是否有事件发生,有的话就立即将该事件通知工作线程(逻辑单元)。除此之外,主线程不做任何其他实质性的工作。读写数据,接受新的连接,以及处理客户请求均在工作线程中完成。
- Proactor
- 通常用异步IO来实现
- 与Reactor模式不同,Proactor模式将所有I/O操作都交给主线程和内核来处理,工作线程仅仅负责业务逻辑。
逻辑单元
并发模式
并发模式是指IO处理单元和多个逻辑单元之间协调完成任务的方法。服务器主要有两种并发编程模式:半同步/半异步(half-sync/half-async)模式和领导者/追随者(Leader/Followers)模式。
- 半同步/半异步模式
- 在并发模式中,“同步”指的是程序完全按照代码序列的顺序执行,“异步”指的是程序的执行需要由系统事件来驱动。常见的系统事件包括中断、信号等。
- 异步线程用于处理I/O事件,同步线程用于处理客户逻辑
- 领导者/追随者模式
- 领导者/追随者模式是多个工作线程轮流获得事件源集合,轮流监听、分发并处理事件的一种模式。在任意时间点,程序都仅有一个领导者线程,它负责监听IO事件。在任意时间点,程序都仅有一个领导者线程,它负责监听IO事件。而其他线程则都是追随者,它们休眠在线程池中等待成为新的领导者。当前的领导者如果检测到IO事件,首先要从线程池中推选出新的领导者线程,然后处理IO事件。此时,新的领导者等待新的I0事件,而原来的领导者则处理IO事件,二者实现了并发。
高效的逻辑处理方式
有限状态机
提高性能建议
- 使用池:(线程池、内存池),已空间换时间,使用时无需动态分配,节省时间
- 避免数据复制(内核态与用户态之间)
- 如sendfile、splice零拷贝函数
- 上下文切换和锁
- 避免过多的工作线程,可允许线程处理多个连接
- 避免锁或减小锁的粒度,如使用读写锁
IO复用
目的:在节省性能的条件下支持并发
select、pool、epoll_wait是用于非阻塞IO上的等待技术,内核查询是否有fd准备好进行读写操作,有则通知用户
参考链接:https://juejin.im/post/6844904200141438984
参考链接:https://www.jianshu.com/p/dfd940e7fca2
select
select返回读就绪、写就绪、异常的fd数量,而异常情况只有一种:socket接收到带外数据。接收带外数据也使用recv函数,但其中的flags需设置为MSG_OOB,而接收普通数据设置为0
带外数据常用来发送一些重要数据

poll

epoll
epoll_creat()、epoll_ctl、epoll_wait()

从实现原理上来说,select 和poll采用的都是轮询的方式,即每次调用都要扫描整个注册文件描述符集合,并将其中就绪的文件描述符返回给用户程序,因此它们检测就绪事件的算法的时间复杂度是O(n)。epoll_wait 则不同,它采用的是回调的方式。内核检测到就绪的文件描述符时,将触发回调函数,回调函数就将该文件描述符上对应的事件插入内核就绪事件队列。内核最后在适当的时机将该就绪事件队列中的内容拷贝到用户空间。因此epoll_wait无须轮询整个文件描述符集合来检测哪些事件已经就绪,其算法时间复杂度是O(1)。但是,当活动连接比较多的时候,epoll_wait 的效率未必比select和poll高,因为此时回调函数被触发得过于频繁。所以epoll_wait 适用于连接数量多,但活动连接较少的情况。
内核需要将消息传递到用户空间,所以select和poll都需要内核拷贝动作;而epoll使用共享内存(mmap)来实现,不需要复制
使用IO复用机制时,最好将IO设置为非阻塞,当使用epoll的边缘触发时,则必须将其设置为非阻塞IO
原因:非阻塞 I/O 的处理方式:循环的 read 或 accept,直到读完所有的数据(抛出 EWOULDBLOCK 异常);阻塞 I/O 的处理方式:每次只能调用一次 read 或 accept,因为多路复用只会告诉你 fd 对应的 socket 可读了,但不会告诉你有多少的数据可读,所以在 handle_read/handle_accept 中只能 read/accept 一次,你无法知道下一次 read/accept 会不会发生阻塞。所以只能等 ioloop 的第二次循环,ioloop 告诉你 fd 可用后再继续调用 handle_read/handle_accept 处理,然后再循环第三次。
故阻塞IO每次只能读取一次数据,而非阻塞IO则可以一次读完
参考https://www.zhihu.com/question/23614342/answer/1418927644和https://www.zhihu.com/question/23614342/answer/1425445011
同步
线程同步
- 互斥锁
- 自旋锁:比较消耗CPU,适用于锁被持有时间短而不希望在线程切换产生开销的情况
- 读写锁
- 条件变量:可以以原子的方式阻塞进程,直到某个特定条件为真为止。对条件的测试是在互斥锁的保护下进行的。条件变量始终与互斥锁一起使用
- 信号量
- 屏障:屏障允许每个线程等待,直到所有的合作线程都达到某一点,然后从该点继续执行
进程同步
- 信号量
- 读写锁
- 屏障
- 记录锁(对文件按部分进行加锁)
- 也可用shared_ptr来管理共享数据:即访问共享数据时,先查看引用计数值是否为1
进程间通信
- 管道(数据量小于PIPE_BUF是原子操作)
- 命名管道
- 共享内存
- 套接字
- 消息队列
- 信号量
- 信号
信号
Linux下的信号采用的异步处理机制,信号处理函数和当前进程是两条不同的执行路线。具体的,当进程收到信号时,操作系统会中断进程当前的正常流程,转而进入信号处理函数执行操作,完成后再返回中断的地方继续执行。
为避免信号竞态现象发生,信号处理期间系统不会再次触发它。所以,为确保该信号不被屏蔽太久,信号处理函数需要尽可能快地执行完毕。
一般的信号处理函数需要处理该信号对应的逻辑,当该逻辑比较复杂时,信号处理函数执行时间过长,会导致信号屏蔽太久。
这里的解决方案是,信号处理函数仅仅发送信号通知程序主循环,将信号对应的处理逻辑放在程序主循环中,由主循环执行信号对应的逻辑代码。
统一事件源
统一事件源,是指将信号事件与其他事件一样被处理。
具体的,信号处理函数使用管道将信号传递给主循环,信号处理函数往管道的写端写入信号值,主循环则从管道的读端读出信号值,使用I/O复用系统调用来监听管道读端的可读事件,这样信号事件与其他文件描述符都可以通过epoll来监测,从而实现统一处理。