操作系统之网络编程心得

操作系统中最慢的部分就是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 万请求,主要会受两个方面的限制:

  1. 文件描述符,在Linux下,单个进程打开的文件描述符数是有限制的,服务器有监听Socket 和 真正用来传数据的已连接Socket

  2. 系统内存,每个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
2
3
4
5
6
7
8
9
10
11
12
13
int s = socket(AF_INET, SOCK_STREAM, 0);
bind(s, ...);
listen(s, ...)

int epfd = epoll_create(...);
epoll_ctl(epfd, ...); //将所有需要监听的socket添加到epfd中

while(1) {
int n = epoll_wait(...);
for(接收到数据的socket){
//处理
}
}
  1. epoll在内核里使用红黑树来跟踪进程所有待检测的文件描述符,通过epoll_ctl()函数把需要监控的socket加入内核中的红黑树。只需要传入一个待检测的socket,减少了数据拷贝和内存分配

  2. 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多线程的方案更简单。

  • 主线程和子线程分工明确,主线程只负责接收新连接。

  • 主线程和子线程交互简单,主线程分配新连接,子线程无需返回数据给主线程。

NettyMemcache采用多Reactor多线程方案

Nginx采用多Reactor多进程方案,但没有MainReactor,而是由SubReactor来accept连接,并加入同一进程多Reactor进行后续处理。通过锁控制一次只有一个进程进行accept,防止出现惊群现象