任务类型
程序任务类型一般分 CPU密集型 和 IO密集型。
CPU密集型任务,指:进行大量计算,消耗CPU资源的任务,eg. 图像处理、音视频编码解码、复杂的科学计算等。
IO密集型任务,指:涉及大量网络、磁盘等比较耗时的输入输出任务,eg. Web服务、数据库操作等。
不论CPU密集型任务还是IO密集型任务,为了提高处理能力,从软件层面入手,有几种主流方式:启动多个服务实例(多进程、多线程、多协程)、IO多路复用、异步IO。
实际应用时,会结合任务类型在上述方式中选择多种进行组合,以此搭建适合的高性能服务框架,eg. Apache 属于多进程多线程并配合 select 模型进行IO处理、Nginx 主要是 epoll 模型(IO多路复用) + 多进程。
IO 和 Socket
Socket 又称 套接字,是一种进程间通信机制,在系统层面上封装了通过 IP + Port 进行 TCP / UDP 数据传输的接口,介于 OSI(Open System Interconnection)模型的传输层与应用层之间。
严格来说,Socket 不归属于 OSI 的任何一层,它是应用层与 TCP/IP 协议族通信的中间软件抽象层。
在设计模式中,Socket 其实就是一个门面模式,它把复杂的 TCP/IP 协议族隐藏在 Socket 接口后面。
Socket 还可以用在同一主机不同进程间的通信(本地 Socket),且建立的是双向的通信。本地 Socket 与 网络 Socket 共用同一套接口,只是地址结构与某些参数不同。
进程间通信(IPC,Inter-Process Communication)方式,除了 Socket,还有:管道 Pipe、命名管道 FIFO、信号 Signal、消息队列 Message Queue、共享内存 Shared Memory。
网络IO的本质是通过 Socket 对网卡进行读写,Socket 在 linux 系统被抽象为流,IO可以理解为对流的操作。
普通的网络传输步骤如下:
- 操作系统将数据从磁盘复制到操作系统内核的页缓存(内核缓冲区)中。
- 应用将数据从内核缓冲区复制到应用的进程缓冲区中。
- 应用将数据从进程缓冲区写回内核的 Socket 缓冲区中。
- 操作系统将数据从 Socket 缓冲区复制到网卡,然后将其通过网络发出。
注:上述过程可以通过 Zero-Copy(零拷贝机制) 进行优化,关于 Zero-Copy 后续再开篇来讲。
同步 / 异步 / 阻塞 / 非阻塞
单从字面上看这几个概念,我们很容易把同步等同于阻塞,异步等同于非阻塞,但实际上,不同的层面有不同的理解。
- 进程通信层面
- “阻塞”是指进程在发起一个系统调用(System Call)后,由于该系统调用的操作(一般是涉及I/O操作,比较耗时)不能立即完成,需要等待一段时间,于是内核将进程挂起为等待(waiting)状态,以确保它不会被调度执行,占用CPU资源。
- 非阻塞I/O系统调用(Non-Blocking I/O System Call)指一个非阻塞调用不会因I/O操作而挂起执行程序,而是会立即返回。
- 异步I/O系统调用(Asychronous I/O System Call)也属于非阻塞系统调用(Non-Blocing System Call),不同的是,I/O操作完成后,操作系统会通知调用进程(设置一个用户空间特殊的变量值 或者 触发一个 Signal 或者 产生一个软中断 或者 调用应用程序的回调函数)。
不同于非阻塞I/O系统调用,异步I/O系统调用的结果必须是完整的,但这个操作完成的通知可以延迟到将来的某个时间点,即,返回结果的方式和内容有所差别。
- IO系统调用层面
(单线程背景下)多数I/O系统调用是同步的,真正的异步I/O是需要内核级支持的,eg. libaio。
非阻塞系统调用(Non-Blocking I/O System Call 与 Asynchronous I/O System Call)可以用于实现线程级别的 I/O 并发,与通过多进程实现的 I/O 并发相比可以减少内存消耗以及进程切换的开销。
举个栗子:我们知道,JS是单线程的,那为什么 Ajax 可以实现异步 HTTP 请求?
本质还是事件驱动,即,把事件加入队列,通过 Event Loop 去轮询队列,待JS线程空闲时再执行回调函数。
所以,对于JS线程来讲,Ajax 是异步的,但在I/O调用上确实还是同步的。
注:JS引擎确实是单线程运行的,而浏览器会另开线程专门处理 Event Loop、HTTP 请求、界面渲染等。
IO过程
传统I/O的大致过程如下:
内核空间 / 用户空间
虚拟内存被操作系统划分为两块:内核空间 和 用户空间,为了安全,它们是隔离的。
内核空间是内核代码运行的地方,可以调用系统的一切资源;用户空间是用户程序代码运行的地方,不能直接调用系统资源,必须通过系统接口(System Call)才能向内核发出指令。
进程运行在内核空间时就处于内核态,运行在用户空间时就处于用户态,通过系统接口,进程可以从用户空间切换到内核空间。
1 | str = "my string" // 用户空间 |
上面代码中,第一行和第二行都是简单的赋值运算,在 User Space 执行。第三行需要写入文件,就要切换到 Kernel Space,因为用户空间不能直接写文件,必须通过内核。第四行又是赋值运算,就切换回 User Space。
过程简述
整个I/O过程可以划分为两个阶段:I/O调用阶段 和 I/O执行阶段。
- I/O调用阶段 - 进程向内核发起系统调用。
- I/O执行阶段 - 内核执行I/O 并 拷贝数据至进程缓冲区 或 _向进程反馈写成功_。
- 数据准备 - 内核在收到指令后需要做一系列 把数据从I/O设备写到内核缓冲区(读) 或 把进程缓冲区的待写数据替换到内核缓冲区中(写) 的准备工作。
- 数据拷贝 - 读过程中,数据准备好后,从内核缓冲区拷贝数据到进程缓冲区;写过程不需要拷贝数据,内核只需在合适时机再将脏页刷入磁盘。
I/O模型
Richard Stevens 在《Unix Network Programming Volume 1, Third Edition: The Sockets Networking API》6.2节“I/O Models”中提出了Unix下可用的5种I/O模型。
Blocking I/O
阻塞I/O,简称 BIO,Blocking I/O,是最常见的I/O模型,默认情况下,所有套接字都是阻塞的。以数据报(UDP)套接字为例:
其中,recvfrom函数视为系统调用,会从在应用进程空间中运行切换到在内核空间中运行,直到数据报到达且被复制到应用进程的缓冲区中或者发生错误(最常见的错误是系统调用被信号中断)才返回。
进程在从调用recvfrom开始到它返回的整段时间内是被阻塞的,recvfrom成功返回后,应用进程开始处理数据报。
BIO比较影响应用性能,虽然可以通过多线程来提升,但线程的创建销毁与切换同样消耗系统资源、影响性能。
Non-Blocking I/O
非阻塞I/O,简称 NIO,Non-Blocking I/O,即,内核收到进程发起的系统调用请求后会立即返回,之后进程通过轮询(polling)的方式来不断请求获取处理结果。
上图中,前几次调用recvfrom时,数据还未准备好,内核立即返回一个EWOULDBLOCK错误;之后再次调用recvfrom时数据报已准备好,并从内核缓冲区复制到进程缓冲区,完成后返回成功。
EWOULDBLOCK - 表示该操作可能被阻塞,其中,E是Error,WOULD BLOCK表示可能被阻塞。
NIO中,进程需要不断地询问内核数据是否就绪,这会耗费大量CPU时间。
值得注意的是:BIO中进程(或线程)被阻塞,但不占用CPU,只因一个进程(或线程)只能处理一个任务,比较影响应用性能。
I/O Multiplexing
I/O多路复用,I/O Multiplexing,Select 是内核提供的系统调用,它支持一次查询多个系统调用的可用状态,当任意一个结果状态可用时就会返回,用户进程再发起一次系统调用进行数据读取。
换句话说,NIO中轮询的系统调用,借助 Select,只需要发起一次系统调用就够了。
进程阻塞于select调用,等待数据报套接字变为可读;当select返回套接字可读这一条件时,再调用recvfrom把所读数据报复制到进程缓冲区。
相比BIO,select 的优势在于可以等待多个描述符就绪。
与I/O多路复用相对应的是在多线程中使用BIO,每个文件描述符一个线程,这样每个线程都可以自由地阻塞调用recvfrom。
Signal-Driven I/O
信号驱动I/O,Signal-Driven I/O,即,让内核在描述符就绪时发送 SIGIO信号 通知应用。
通过 sigaction 系统调用安装一个信号处理程序,当数据报准备好读取时,内核就为该进程产生一个SIGIO信号;
信号处理程序捕获SIGIO信号并调用recvfrom读取数据报(也可以直接通知应用去调recvfrom),然后通知应用进程开始处理。
信号驱动I/O与BIO和NIO最大的区别在于:在I/O执行的数据准备阶段,不会阻塞用户进程。
信号驱动I/O有种异步操作的感觉,但在I/O执行的第二阶段(也就是将数据从内核复制到用户空间),用户进程还是被阻塞的。
Asynchronous I/O
异步I/O,简称 AIO,Asynchronous I/O,由 POSIX标准 定义(POSIX标准 定义了aio_*系列异步I/O函数)。
POSIX,Portable Operating System Interface of Unix,可移植操作系统接口,X 表明其对 Unix API 的传承。
POSIX标准由IEEE(电气和电子工程师协会)发布,是IEEE为要在各种UNIX操作系统上运行软件而定义的一系列API标准的总称。
异步I/O真正实现了I/O全流程的非阻塞:进程发出系统调用后立即返回,内核在数据准备完成后将数据拷贝到进程缓冲区,然后发送信号告诉进程I/O操作执行完毕。
与信号驱动I/O相比,异步I/O是由内核在I/O操作执行完成后通知应用。
I/O多路复用实现机制
select、poll、epoll都是I/O多路复用的实现机制。
select
过程简述
- 应用中维护待检查的FD_SET(Socket集合),然后调用 select 函数。
- 内核逐个检测FD_SET中的Socket,若某个Socket状态有变化,则填入内部分配的一个数组,待所有Socket检测完成,再将此数组拷贝到对应的FD_SET,然后返回。
- 应用从返回的 select 函数获取有对应状态变化的FD_SET,然后对这些Socket进行IO操作。
select 会阻塞并监视三类(读/写/异常)文件描述符,等有数据(读就绪列表/写就绪列表/异常列表)或超时,就会返回。
1 | int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout); |
其中,nfds 是 readfds、writefds、exceptfds 中编号最大的那个文件描述符加1(加1是因为文件描述符从0开始计数),readfds 存放读就绪的文件描述符列表,writefds 存放写就绪的文件描述符列表,exceptfds 存放出现异常的文件描述符列表。timeout 为最大阻塞时间长度(精确到毫秒)…>>
优缺点
优点:有较多系统实现了 select 接口,跨平台更容易。
缺点:对监听的文件描述符有上限(一般是1024);应用需维护FD_SET,并与内核来回传递,复制开销大;内核轮询扫描待检测FD_SET,占用较多CPU时间,效率低。
select 中,为什么监听的文件描述符有上限?
FD_SET 是个 Bitmap,每个 bit 都表示对应的描述符是否监听(即使相应位置的描述符不需要监听,在 FD_SET 里也有它的 bit 存在),最多 nfds 个 bit(nfds 即代表了 fd_set 的长度:fd_set[0] ~ fd_set[nfds - 1]
)。
而 Linux 系统中,文件描述符是有上限的,Soft limit 默认为1024。
虽然可以改 Soft limit(ulimit -n <文件数目>
或 修改 /etc/security/limits.conf 文件),但 select 中的 FD_SETSIZE 也默认设置了1024,还得重新编译内核。
poll
本质上与 select 一致,区别是使用链表存储FD,解决了最大文件描述符的限制,但内核依然要遍历所有FD且同样需要复制数据。
1 | int poll(struct pollfd *fds, nfds_t nfds, int timeout); |
nfds 是 fds 数组的长度,struct pollfd 定义如下:
1 | struct pollfd { |
可以看出,fds 的核心还是文件描述符,通过 events 和 revents 来标识已变化的监听事件。
https://man7.org/linux/man-pages/man2/poll.2.html
epoll
epoll 的核心是 Event-Driven(事件驱动)模型,即,注册监听事件,通过回调维护就绪事件队列,以便在系统调用时返回给应用,进而完成I/O操作。
三个核心函数:
epoll_create()
- 初始化,构建 epoll 对象,用于存储 fd 和 就绪事件。epoll_ctl()
- 增删改 epoll 对象中的 fd 并注册监听事件。epoll_wait()
- 阻塞等待以获取就绪事件,以便应用继续完成I/O操作。
函数定义
epoll_create
1 | int epoll_create(int size); |
https://man7.org/linux/man-pages/man2/epoll_create.2.html
返回一个 epoll 文件描述符(epfd),size 用于指定最大监听的 fd + 1
的值。
epfd 会占用一个 fd 值,若不再使用,必须调用 close()
关闭,否则可能导致 fd 被耗尽。
当所有引用 epfd 的文件描述符均已关闭,内核也会销毁此对象并释放相关联资源。
epoll_create1
1 | int epoll_create1(int flags); |
https://man7.org/linux/man-pages/man2/epoll_create1.2.html
epoll_create1 扩展了 epoll_create 的功能,当 flags = 0 时,两者等价。
当 flags = EPOLL_CLOEXEC 时,在进程替换映像(exec* 函数组,用于把当前进程替换为一个新进程,新进程与原进程有相同的PID;实际上是调用 fork 复制一个新的子进程,利用 exec* 系统调用将新产生的子进程完全替换父进程)的时候会关闭这个文件描述符,这样新的映像中就无法对这个文件描述符进行操作,用于多进程及映像替换的情况。
返回值:成功时返回一个文件描述符;出错时返回 -1。
epoll_ctl
1 | int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); |
https://man7.org/linux/man-pages/man2/epoll_ctl.2.html
op,要执行的命令:EPOLL_CTL_ADD - 向 epfd 添加一个连接 socket 的文件描述符;EPOLL_CTL_MOD - 改变 epfd 中一个文件描述符的监听事件;EPOLL_CTL_DEL - 移除 epfd 中一个文件描述符。
fd,要操作的文件描述符。
epoll_event 结构:
1 | typedef union epoll_data { |
events 为以下零个或多个可用事件进行的或运算:
- EPOLLIN - 对应的文件描述有可以读取的内容。
- EPOLLOUT - 对应的文件描述符可以写入。
- EPOLLRDHUP - 写到一半的时候连接断开。
- EPOLLPRI - 发生异常情况,eg. TCP连接中收到了带外数据。
- EPOLLET - 设置 epfd 中 fd 的事件触发机制为边缘触发(Edge Trigger),默认为水平触发(Level Trigger)。
- EPOLLERR - 对应的文件描述符发生错误。
- EPOLLHUP - 对应的文件描述符被挂断。
- EPOLLONESHOT - 只监听一次事件,当监听完这次事件之后,如果还想继续监听,需要再次把 socket 对应的 fd 加入到 epfd 里。
- EPOLLWAKEUP(Linux 3.5之后新增) - 如果 EPOLLONESHOT 和 EPOLLET 清除了,并且进程拥有 CAP_BLOCK_SUSPEND 权限,那么此标志能够保证事件在挂起或者处理的时候,系统不会挂起或休眠。
- EPOLLEXCLUSIVE(Linux 4.5之后新增) - 保证一个事件发生时只有一个线程会被唤醒,以避免多侦听下的“惊群”问题;不过任一时候只能有一个工作线程调用 accept,限制了真正并行的吞吐量。
epoll_wait
1 | int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout); |
https://man7.org/linux/man-pages/man2/epoll_wait.2.html
events,从内核得到的事件集合。
maxevents,告诉内核 events 的大小,maxevents 的值不能大于 epoll_create()
的 size。
timeout,超时时间(毫秒,0 立即返回,-1 永久阻塞)。
返回值:需要处理的事件数量,返回 0 表示已超时。
基本用法
https://man7.org/linux/man-pages/man7/epoll.7.html 提供了使用示例。
1 |
|
核心设计
在 epoll 文件系统里新建一个文件,此文件对应 eventpoll 结构体。
1 | struct eventpoll { |
在内核缓冲区里构建一棵红黑树,用于存储所有添加到 epoll 的事件。
事件都会与设备(eg. 网卡)驱动程序建立回调关系,相应事件发生时会执行回调函数 ep_poll_callback
。
回调函数中把就绪事件添加到 rdllist 双向链表。
每一个事件都对应一个 epitem 结构体。
1 | struct epitem { |
当调用 epoll_wait 检查是否有就绪事件时,只需检查 eventpoll 对象中的 rdllist 是否有 epitem 元素;
若 rdllist 不为空,则复制事件到进程缓冲区(使用 mmap 提升效率),并返回事件数量。
红黑树对查询、新增、删除的效率极高,所以通过 epoll_ctl 向 epoll 对象增删改事件时非常快。
mmap 是目前 Linux 进程间通信中传递最快、消耗最小、传递数据过程不涉及系统调用的方法,这极大降低了大量FD时数据拷贝的消耗。
触发模式
epoll 有两种触发模式:边缘触发(Edge Trigger),简称 ET;水平触发(Level Trigger),简称 LT;默认为 LT。
- LT 模式
只要某个文件描述符还有数据可读,则每次 epoll_wait 都会返回此文件描述符的事件,提醒应用程序去操作。 - ET 模式
在检测到有 I/O 事件时,通过 epoll_wait 调用会得到就绪事件的文件描述符,对于每一个就绪事件的文件描述符,如可读,应用程序需要以 Non-Blocking 方式将该文件描述符一直读完(非阻塞情况下,没有数据可读时,read()
会返回 EAGAIN),否则下次 epoll_wait 不会返回该事件,直到该文件描述符再次可读时才通知。
为什么需要 ET 模式?
如果你只关心部分就绪文件描述符,但每次调用 epoll_wait 都会返回大量无关的事件,那么,程序会额外增加很多无效的遍历判断。
而 ET 模式下,当被监控的文件描述符上有可读写事件发生时,epoll_wait 才会返回给处理程序去读写。
但,如果当次通知没有把数据全部读写完(eg. 读写缓冲区太小),那么就有可能导致数据丢失。
惊群问题
当多个进程(或多个线程)同时阻塞等待(休眠状态)一个事件的发生,如果某个时间点等待的这个事件发生了,那么它就会唤醒所有等待的进程(线程),但是最终只能有一个进程(线程)获得资源,对事件进行处理,而其他进程(线程)只能重新进入休眠状态,这种现象就叫做惊群(Thundering herd)效应。
惊群效应带来哪些问题?
- 最终只能有一个线程获得资源,所以理想情况下只需唤醒一个线程,而唤醒多个线程导致了不必要的线程调度,造成系统开销。
- 其次,为了确保只有一个线程得到资源,需要对资源进行加锁保护。
eg. Nginx 通过锁机制解决惊群问题。
单线程处理 epoll_wait 是没有惊群问题的,但引入多线程就会面临惊群问题,针对不同的多线程处理I/O场景有不同的解决方式:
- fd 注册在一个 epfd 上,多进程(可能是通过 fork 产生的,eg. Nginx 的 Worker 进程就是通过 Master 进程 fork 出来的,Master 和 Worker 是父子进程)共享 fd,一起处理 fd 的 I/O 事件。
就绪事件可能会将父子进程都唤醒,即,出现惊群。
解决方式:使用 ET 模式,即,fd 的就绪事件只通知一次,这样保证了每次只会唤醒一个进程去处理事件。 - fd 注册到多个不同的 epfd 上,且每个 epfd 由不同线程负责调用 epoll_wait,这些线程共同处理 fd 的 I/O 事件。
就绪事件可能会在多个 epfd 上将阻塞在 epoll_wait 的线程都唤醒,于是出现惊群。
解决方式:epoll_wait 时带上 EPOLLEXCLUSIVE 参数,保证同一个 fd 产生的 I/O 事件只唤醒一个线程来处理;此方式同时解决了第一种场景的惊群问题。
实际中,解决方案远没有上面描述的两种方式来的简单,不过Linux内核(2.6+)基本为我们解决了常见的惊群问题(通过互斥信号量?)。
kqueue
kqueue 是 MacOS 和 FreeBSD 下的实现。
实际应用
epoll 在实际中有着广泛的应用。
我们能从众多开源项目中发现它的身影,eg. Redis、Nginx、Node.js、Swoole 以及 Java 中的 NIO(New I/O)等等。
几个问题
Socket 为什么称 套接字?
知乎上有个「Socket 为什么要翻译成套接字?」的话题,通过下面这句话(原样摘录),大体理解下。
Socket 提供一种供应用程序访问通信协议的操作系统调用,并且通过将 Socket 与 Unix 系统文件描述符相整合,使得网络读写数据(或者服务调用)和读写本地文件一样容易。很显然,Socket 已经离“插座”越来越远了,已经完全不再是硬件上的物件,而是一序列的“指令”,按汉语的理解,已经具备了“套接”(建立网络通讯或进程间通讯)和“字”(可交互的有序指令串)的概念。
异步一定是多线程吗?
应用层面的多线程是模拟实现异步的一种方法,实际上,在软件系统中,异步的真正实现需要底层操作系统的支持。
Linux AIO(libaio)利用了 CPU 和 IO 设备异步工作的特性,与同步 IO 相比,很大程度上减少了 CPU 资源的浪费。
而 POSIX AIO(glibc) 利用了线程与线程之间的异步工作特性,在用户线程中实现 IO 的异步操作。
所以,libaio 是 I/O系统调用层面(内核态)的异步,而 glibc 是进程通信层面(用户态)的异步。
简单讲,多线程(glibc/…)、多路复用(select/poll/epoll)、异步I/O(libaio)是实现异步的常见手段。
为什么说 epoll 是 同步非阻塞的?
这其实是在不同层面上的理解:
非阻塞 - 体现在I/O请求后把 Socket 写到缓冲区,底层硬件实现了DMA机制,才能够注册回调函数,这之后进程不需要阻塞等待。
同步 - epoll 本身要阻塞在那里等待 Socket,也就是同步I/O。
为什么分 监听socket 和 连接socket?
先来看看 Socket 建立连接的过程:
- 客户端
socket()
- 创建出 active_socket_fd(client_socket_fd)。bind()
- 把 active_socket_fd 与 ip,port 绑定起来。connect()
- client_socket_fd 主动请求服务端的 listen_socket_fd。read() / write()
- 读/写 socket io。close()
- 关闭 socket_fd。 - 服务端
socket()
- 创建出 active_socket_fd。bind()
- 把 active_socket_fd 与 ip,port 绑定起来。listen()
- active_socket_fd -> listen_socket_fd,等待客户端的 client_socket_fd 来请求连接。accept()
- listen_socket_fd -> connect_socket_fd,创建 连接socket,用于建立连接后的读写数据。read() / write()
- 读/写 socket io。close()
- 关闭 socket_fd。
监听socket(listen_socket_fd)存在于服务端的整个生命周期,不需要为每个连接都创建一个新的监听socket。
连接socket(connect_socket_fd)是客户端与服务端之间已经建立起连接的一个端点,服务端每次接受连接请求都会创建一个新的连接socket,它的生命周期是客户端请求服务端的时间范围。
有了 listen_socket_fd 和 connect_socket_fd,就可以用 listen_socket_fd 负责响应客户端的请求,而每次创建新的 connect_socket_fd 专门负责当次连接的数据读写。
总的来说,是为了分层协作,提高服务端性能。
参考资料
怎样理解阻塞非阻塞与同步异步的区别
User space 与 Kernel space
计算机文件读写原理
《UNIX网络编程卷1:套接字联网API(第3版)》6.2节
深入学习理解 IO 多路复用
如果这篇文章说不清epoll的本质,那就过来掐死我吧
为什么有监听socket和连接socket
epoll原理详解及epoll反应堆模型