操作系统之网络编程心得
操作系统中最慢的部分就是IO:
网络 毫秒级~秒级,HDD 毫秒级,SSD 微秒级,内存 纳秒级
零拷贝 和 异步IO
文件传输 内存拷贝 上下文切换
一次数据传输,可能涉及多次内存拷贝,内核与用户态间的拷贝带来系统调用,系统调用需要两次上下文切换。
首先,
上下文切换
需要 几十纳秒 ~ 几微秒,高并发环境容易被累积放大;操作系统同时运行几百个线程就很影响性能其次,
内存拷贝
也会消耗CPU资源,CPU搬运数据是利用寄存器一次一个字节地搬
DMA
DMA帮CPU省去在IO设备缓冲区和内核缓冲区之间的数据搬运,但CPU还是要在内核缓冲区和用户缓冲区之间搬运。
一次发送文件,经历 read和write 两次系统调用,四次内存拷贝(磁盘缓冲区-PageCache-用户态缓冲区-socket内核缓冲区-网卡缓冲区)。
零拷贝
所谓的零拷贝技术
,就是没有在内存层面去拷贝数据,全程没有通过CPU来搬运数据,所有的数据都是通过DMA来进行传输。
mmap + write
mmap系统调用函数把内核缓冲区里的数据映射
到用户空间。
省去在内核缓冲区和用户缓冲区之间搬运数据,变成三次内存拷贝,但 mmap和write 还是要两次系统调用。
sendfile
在Linux内核版本2.1中,提供了一个专门发送文件的系统调用函数sendfile,指定源fd和目的fd,实现一次系统调用,三次内存拷贝。
SG-DMA
如果网卡支持 The Scatter-Gather Direct Memory Access 技术,就可以让sendfile系统调用只进行两次内存拷贝。只需要给socket缓冲区传fd和数据长度,就可以直接从PageCache拷贝到网卡缓冲区。
PageCache
利用局部性原理
,缓存最近访问的磁盘数据,按lru
淘汰。
内核优化:
内核IO调度算法会缓存尽可能多请求在PageCache中,最后
合并
成一个更大的IO请求再发给磁盘,减少磁盘的寻址操作利用
预读
功能,从顺序读的性能优势中获益。
但是不适合传输GB级别大文件,(1)不适用局部性,浪费DMA从磁盘缓冲区到PageCache的拷贝,(2)PageCache长时间被大文件占据,热点小文件无法利用PageCache
缓存IO 直接IO
缓存IO利用PageCache;直接IO,绕开了PageCache,DMA直接将数据从磁盘缓冲区搬运到用户缓冲区。
直接IO场景,(1)传输大文件,(2)应用程序自己实现了磁盘数据缓存,想绕开PageCache提高性能。
直接IO失去PageCache的合并和预读的优势。
异步IO
异步IO发起系统调用后,不用等待数据就位直接返回;磁盘缓冲区搬运到用户缓冲区后,进程收到内核通知再处理数据。
在高并发的场景下,针对大文件的传输的方式,应该使用异步IO+直接IO
来替代零拷贝技术。
IO多路复用
C10K问题
如果实现C10K服务器,即支持并发 1 万请求,主要会受两个方面的限制:
文件描述符,在Linux下,单个进程打开的文件描述符数是有限制的,服务器有
监听Socket
和 真正用来传数据的已连接Socket
系统内存,每个TCP连接在内核中都有对应的数据结构
真正实现C10K的服务器,要考虑的地方在于服务器的网络IO模型,效率低的模型会加重系统开销
多进程模型
正因为子进程会复制父进程的文件描述符,可以直接使用已连接Socket
和客户端通信了。子进程不需要关心监听Socket
,只需要关心已连接Socket
;父进程则相反。
父进程利用wait()
函数回收已退出的子进程,减少僵尸进程。
进程间上下文切换非常影响性能。
多线程模型
进程和线程的上下文切换
进程不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。
同进程里的线程可以共享进程的部分资源,比如文件描述符列表、进程空间、代码、全局数据、堆、共享库等,这些共享些资源在上下文切换时不需要切换,而只需要切换线程的私有数据、寄存器等不共享的数据。
线程池
如果每来一个连接就创建一个线程,那么频繁 创建和销毁线程 带来不小的系统开销。
可以使用线程池的方式来避免这种情况,提前创建若干个线程,这样当由新连接建立时,将这个已连接Socket
放入到一个队列
里,然后线程池里的线程负责从队列中取出进行处理。这个队列是全局的,每个线程都会操作,为了避免多线程竞争,线程在操作这个队列前要加锁
。
对于C10K目标,要维护 1 万的线程,操作系统扛不住。
IO多路复用
select/poll/epoll
是内核提供的多路复用系统调用,进程可以同时监听多个Socket
,通过一个系统调用函数从内核中获取多个事件。在获取事件时,先把所有连接(文件描述符)传给内核,再由内核返回产生了事件的连接,然后在用户态中再处理这些连接对应的请求即可。
select/poll
select,使用固定长度的BitsMap
表示文件描述符集合,个数受内核中的FD_SETSIZE
限制,默认监听0~1023的文件描述符。。需要进行2次遍历
文件描述符集合,一次是在内核态里,一个次是在用户态里,而且还会发生2次拷贝
文件描述符集合,先从用户空间传入内核空间,由内核修改后,再传出到用户空间中。
poll,使用动态数组,以链表形式来组织,突破了select的文件描述符个数限制,当然仍会受到系统定义的进程打开的最大文件描述符个数限制。
poll\select并没有太大的本质区别,都是使用线性结构
存储进程关注的Socket集合,因此都需要遍历文件描述符集合来找到可读或可写的Socket,时间复杂度为O(n),而且也需要在用户态与内核态之间拷贝文件描述符集合,这种方式随着并发数上来,性能的损耗会呈指数级增长。
epoll
1 | int s = socket(AF_INET, SOCK_STREAM, 0); |
epoll在
内核里
使用红黑树
来跟踪进程所有待检测的文件描述符,通过epoll_ctl()函数把需要监控的socket加入内核中的红黑树。只需要传入一个待检测的socket,减少了数据拷贝和内存分配。epoll使用
事件驱动
的机制,内核里维护了一个链表
来记录就绪事件,当某个socket有事件发生时,通过回调函数
内核会将其加入到这个就绪事件列表中。用户调用epoll_wait()函数时,只会返回有事件发生的文件描述符,避免了遍历socket集合
epoll的方式在监听的Socket数量越多的时候,效率不会大幅度降低,且能够监听的Socket的数目也非常多。
边缘触发和水平触发
边缘触发,当Socket有可读事件发生时,服务器端只会从epoll_wait中苏醒一次,程序要保证一次性将内核缓冲区的数据读取完
水平触发,当Socket有可读事件发生时,服务器端不断地从epoll_wait中苏醒,直到内核缓冲区数据被read函数读完才结束
边缘触发模式一般和非阻塞IO
搭配使用,程序会一直执行IO操作,直到系统调用(如read和write)返回错误,错误类型为EAGAIN或EWOULDBLOCK。边缘触发的效率比水平触发的效率要高,可以减少epoll_wait的系统调用次数。
非阻塞IO
IO多路复用,最好搭配非阻塞IO一起使用,这是因为在极少数特殊情况下,多路复用API返回的事件并不一定可读写的,使用阻塞IO, 那么在调用read/write时则会发生程序阻塞。比如数据已经到达,但经检查后发现有错误的校验和而被丢弃,文件描述符被错误地报告为就绪。
高性能网络模式
Reactor 模式
Reactor为对事件的反应,收到IO多路复用监听的事件后,根据事件类型Dispatch
给某个进程/线程。Reactor利用面向对象
的思想,原本多路复用面向过程
的方式做了封装,让使用者不用考虑底层网络 API 的细节,只需要关注应用代码的编写。
Reactor模式主要由Reactor
和处理资源池
这两个核心部分组成:
Reactor 负责监听和分发事件,事件类型包含连接事件、读写事件;可以只有一个,也可以有多个;
处理资源池 负责处理事件,如 read -> 业务逻辑 -> send;可以是单个进程/线程,也可以是多个进程/线程;
方案具体使用进程还是线程,要看使用的编程语言以及平台有关:
Java语言一般使用线程,比如 Netty;
C语言使用进程和线程都可以,例如 Nginx 使用的是进程,Memcache 使用的是线程。
单Reactor 单进程/单线程
进程中有三个对象:Reactor对象
,负责监听(select)和分发(dispatch)事件;Acceptor对象
,负责获取连接(accept),并创建一个Handler对象处理后续的事件;Handler对象
,负责处理业务(read, 业务逻辑,send)。
缺点一,无法利用多核CPU的优势;缺点二,Handler对象在业务处理时,整个进程无法处理其他连接的事件。单Reactor单进程的方案不适用计算机密集型的场景,只适用于业务处理非常快速的场景。Redis
在6.0版本之前采用此方案,业务处理主要在内存中完成,操作速度很快,性能瓶颈不在CPU上。
单Reactor 多线程/多进程
Handler对象
不再负责业务逻辑,通过read读取数据后,会将数据发给子线程的Processor对象
进行业务处理,处理完后将结果返回给Handler对象,再通过send方法将响应返回给client。
多线程,可以共享数据,在操作共享资源前加上互斥锁避免竞争。
多进程,要考虑父子进程见的双向通信,不常用。
问题,一个Reactor对象承担所有事件的监听和响应,且只在主线程中运行,在瞬时高并发
场景下,容易成为性能瓶颈。
多Reactor 多进程/多线程
主线程中,
MainReactor对象
收到连接事件后,通过Acceptor对象
获取连接,分配给某个子线程。子线程中,
SubReactor对象
将分配到到连接加入select继续继续监听,同时创建一个Handler对象
用于处理连接(子线程所有连接)的响应事件。
多Reactor多线程方案比单Reactor多线程的方案更简单。
主线程和子线程分工明确,主线程只负责接收新连接。
主线程和子线程交互简单,主线程分配新连接,子线程无需返回数据给主线程。
Netty
和Memcache
采用多Reactor多线程方案
Nginx
采用多Reactor多进程方案,但没有MainReactor,而是由SubReactor来accept连接,并加入同一进程多Reactor进行后续处理。通过锁控制一次只有一个进程进行accept,防止出现惊群现象
。