为什么要学IO学多路复用
为了解决:C10K、C100K (TPS|QPS) 等性能问题。
在多连接的情况下,处理速度的快与慢就决定了技术怎么去选型。
服务端|客户端
既然多路复用就是为了解决多连接情况下的性能问题,那么我们先来看一下建立一个连接都要经过哪些过程。
strace 追踪
strace 是一个可以抓取程序与操作系统之间的交互信息及调用了哪些系统调用的工具。其参数说明及返回值说明可以参考:内核态后端调试神器strace
我们用 strace 追踪一下连接建立的过程:
TcpServer 是一个简单的 Socket Server, 戳这里获取代码
用 strace 追踪 TcpServer
$ strace -ff -o ~/strace_log/php/socket php TcpServer.php
找到 TcpServer 的 PID,如图为: 22427
$ ps -ef | grep TcpServer
看一下 TcpServer 的网络连接情况:
$ netstat -natp | grep 22427
可以看到本机已监听8080端口了。
/proc/PID/
我们知道所有的进程都是运行在内存当中,而内存当中的数据又都是写到 /proc/*
这个目录。基本上当前主机的各个进程的 PID 都是以目录的类型存在于 /proc
当中。
看一下 TcpServer 的运行时的fd(file descriptor):
$ cd /proc/22427/fd/
可以看到已经多了一个 socket 连接的 fd 了。
文件描述符表述指向文件的引用的抽象化,也就是指代了IO文件(如:
file-文件
,socket-网络行为
,pipeline-管道
等);同时可以开启的IO文件数由ulimit -n
约束;你可以在/proc/[PID]/task
内看到进程中包含的所有线程
ulimit
这里先补一个常识:
Linux ulimit命令用于控制shell程序的资源
语法:
$ ulimit [-aHS][-c <core文件上限>][-d <数据节区大小>][-f <文件大小>][-m <内存大小>][-n <文件数目>][-p <缓冲区大小>][-s <堆叠大小>][-t <CPU时间>][-u <程序数目>][-v <虚拟内存大小>]
其中 open files
| ulimit -n
同一时间最多可开启的文件数, max user processes
用户创建最大进程/线程(线程也是轻量级的进程)数。
如:你的程序要读取的文件数超过 ulimit -n
所限制的数量,那么就会报错开不了。
strace 追踪文件
我们再来看下抓取到的文件信息:
$ cd ~/strace_log/php/
$ vi socket.22427
搜索 socket(
:
可以看到调用了关于 socket 的这几个系统调用:socket
、bind
、listen
、accept
- socket()系统调用返回的
3
就是你在fd
目录下看到的文件描述符3; - bind()系统调用给
fd3
绑定了8080
端口; - listen()系统调用监听
fd3
; - accept()系统调用返回的
4
就是在最后在fd
目录下看到的文件描述符4;
注意 accept 阻塞在这儿了。
客户端连接工具 nc
# 没安装 nc 的可以先安装
$ yum -y install nmap-ncat
# 连接刚才启动的 server
$ nc localhost 8080
再来看 /proc/22427/fd
,可以发现此时已经又多了一个 socket 连接了。
同时再查看 netstat -antp
对于这个 client 也多了一个socket:
其中 53830
端口是 client 随机分配的。 server的端口 8080
是固定不变的。
在看 strace 的文件
注意: recvfrom 阻塞
在开始说 IO 模型之前先来补充点操作系统的知识。
操作系统
我们知道程序是跑在内存上的,内存第一个程序是 kennel 内核,kennel 管理着硬件IO设备等资源。应用程序并不能直接去操作内存,IO设备等底层,而是靠 kennel 提供的系统调用去操作。
kennle 与应用程序有一个隔离的关系(隔离进入保护模式),计算机启动的时候,第一个进入内存的是 kennel, CPU 开始读 kennel 内的指令。此时 kennel 会划分内存,内核代码所在区域内核空间-内核态是未来用户创建出的进程不可访问的,剩余的空间叫用户空间-用户态。
但是应用程序还是可以通过 system call
调用kennel呀,这不是犯冲嘛。
其实不是的,应用程序并不会直接去调用 system call
,应用程序想调用kennel会触发一个 0x80
软中断。
没有傻问题
什么是中断? cpu 只有一个的话,某一时间点只有一个程序在运行。
我们从没写过让出 CPU 执行权限的代码,是不是 CPU 只会笨笨的一直执行同一个程序,别的程序根本执行不到呢?肯定不会一直执行同一个程序的,表面上看程序都是并行|同时执行的。
那么执行权限是怎么让出的呢? CPU里面有 晶振
,晶振
的目的就是会产生一些中断。
什么是晶振呢? 一个平滑的电流(———),这个电流经过晶振出去的时候就变成高低电频(---)出去,每震荡一下,就会产生一个中断(比如:时间中断)CPU里面的晶振可以达到秒级千次/万次。
每个中断号在 kennel 在启动的时候都有一个对应的 callback
, callback
里面写的就是进程调度。
假设CPU正在处理nginx, 收到一个中断号,把手头的事放一放,然后CPU根据寄存器在 kennel 中找到这个中断号对应的回调,调用进程调度,然后发现不执行 nginx 了,要执行别的进程了,然后 CPU 就去执行别的进程的指令了。
其实你按下键盘,滑动鼠标都会产生中断。这样即使你只有一个 CPU 所有的事情都会穿插着执行。
晶振: 电子手表也有晶振器,比如:接收到100次就加1秒中.然后芯片有个逻辑,秒到60进一位分.
系统调用
系统调用可以通过 man
帮我们查看系统调用方法说明
man
# 安装
$ yum -y install man-pages
# 2表示:2类系统调用
$ man 2 socket // 去找 VALUE 看看返回什么, (返回一个 fd)
$ man 2 bind // 下面有个 server 的案例
I/O 模型
先简单看下 IO 处理过程。大致可看作一下几步:
- 是否可读 | 是否可写
- 读|写 data
- Q(查询) | T(加工转换)
- 读|写 data
阻塞 socket
通过刚才的 strace 追踪的文件可以知道:
-
accept = fd3 阻塞(在没有 client 连接的时候)
-
recvfrom read fd4 阻塞
fd3 是 server 监听的文件描述符;fd4 是 client 的连接的文件描述符
1. socket 返回 fd3
2. bind fd3 8080
3. listen fd3
4. accept fd3 -> fd4 阻塞 // 等待一个 client 连接之后才有一个返回值,(此时阻塞变为返回)返回一个表示 client连接的文件描述 fd4
while(true){
5. recvform fd4 阻塞!
}
上面结构两个客户端 C2 就阻塞了,更别提C10k了。
优化阻塞 socket
为了上面的问题,用线程去处理 recvform
,就演变为:一线程对应一个连接。
要解决 C10K 问题,就得设置 ulimit -n 10K
,ulimit -u 10K
。你的服务器支持你这么做嘛?
问题:
- 频繁的切换:就算服务器支持这么干,要执行 10K 个线程,CPU 就得轮询这 10K 个线程。(假如比较倒霉,前面都在阻塞,读到最后一个才读到数据,前面的切换就浪费了. CPU浪费在kennel的切换过程,CPU并没有浪费在业务上面。)
- 内存消耗:线程的堆是共享的,但是线程的线程栈是独立的。所以会有内存消耗的问题。
所以现在最大的问题是在阻塞在recvfrom,既然阻塞会带来这么大影响,那么是否可以不阻塞呢?
server 如果在读取一个 client 的时候,server 知道他有没有数据,有就读取,没有就继续往下执行,规避这么阻塞是不是就可以解决这个问题了。
非阻塞 socket
当基于非阻塞 socket 读取数据时,不需要等待内核中的读缓冲区内一定有数据,或者说等待某一个超时时间,read 函数才会返回。而是一旦没有数据 read 立刻返回。如果有数据 read 马上拷贝给 process。
对于 write 也是一样的,如果写缓冲区或者说可用的发送窗口为0的时候,write 立刻就返回告诉我们一个字节也没有写进去。如果有可用的写缓存区时,能写多少就写多少。这些就是非阻塞 socket 上的 read 和 write。
基于非阻塞 socket 我们就有可能在进程中同一时刻处理多个 TCP 连接。
$ man 2 socket
kennel为解决阻塞的问题, 出了一个 SOCK_NONBLOCK
的参数,可以对这个 socket 设置NONBLOCK-非阻塞。
然后就变成 server 轮询所有连接,如果有数据就读取,没有就继续下一个。
$ man 2 fcntl // 这个方法在接受参数的时候可以接受一个非阻塞的文件描述符
这样虽然解决了阻塞的问题,但还是有问题:
- 如果有C10K的连接,那么每次轮询就得调用10K次 recvfrom() 系统调用(复杂度是O(n))
- 而且这C10K的连接如果仅有一个client 在发消息,这样就造成极大的浪费。
现在是由 server 为每一个客户端连接就得问一次内核,那能不能 server 问内核一次就知道哪些该读,哪些不该读,那些不该读的就不读,减少没用读。能不能减少调用次数,也就是复用一次调用就可以了。
多路复用select/poll
- 定义:在一个信道上传输多路信号或数据流的过程和技术
比如对讲机的频分多址(FDMA),时分多址(TDMA),码分多址(CDMA-根据不同的编码)
select|poll
:主动轮询的同步多路复用。
$ man 2 select
这样把所有的 client 的连接 fd
都放入内存,每次select都会返回具体个 fd
可用。这样轮询返回的 fd
集合就好了。
C10K 每循环 O(1) select,及有效的 o(<N) recvfrom。
问题:
- 传递数据,重复传递:
- 虽然每次循环只需要掉一次select()系统调用 ,但是它的传参是10K个
fd
; - 假设有一个 client 频繁给 server 发消息,每次 select 的时候都要把这个 client 的
fd
从用户空间传到内核空间。 每次都得重复传这个fd
。
- 虽然每次循环只需要掉一次select()系统调用 ,但是它的传参是10K个
- kennel主动遍历 O(n): kennel会有一个主动遍历的过程,每次传10K个
fd
, 就得遍历问一下这些 client 谁有数据谁没有数据。
现在要规避这个重复传递的问题。
epoll + 非阻塞 socket
-
epoll 出现:linux 2.5.44
-
功能:进程内同时刻找到(读写)缓冲区或者连接状态(TCP状态变迁)变化的所有 TCP 连接(返回给进程,这样进程就可以基于非阻塞 socket 快速的处理所有的 TCP 连接)
-
三个 API
- epoll_create
- epoll_ctl
- epoll_wait
epoll 为什么高效
- 活跃连接只在总连接的一部分
即使我们同时处理 100W 个连接,但是同一时刻活跃连接可能只有几千个只占总连接的一部分。
epoll 有两个核心的数据结构: 红黑树 和 队列
-
红黑树:存放所有的连接
-
队列:存放发生变化的连接
当读写缓存区或者 TCP 连接发生变化(事件触发),我们就把发生变化的 TCP 连接放到一个队列中。调用 epoll_wait 时只返回(很小的)队列中的 TCP 连接。这样整体的 epoll 就会非常的高效。
注意:epoll 未必你的 kennel 支持。
# 不加2 打开的话是 7类杂项
$ man epoll
可以看到有三个系统调用: epoll_create(),epoll_ctl(),epoll_wait()
为了不重复传递,内核开辟内存空间存这些fd
(listen:fd3
,r/w:fd4
),发送数据的 client 不多。还有一片空间是存有消息的 client。
第一个区域就是 程序 调用 epoll_create()系统调用创建的,然后 程序 每有一个client 连接,就调用 epoll_ctl()系统调用 把 listen:fd3
,r/w:fd4
放进去。
然后有事件/消息 就会从第一个空间放到第二个空间。程序 还得调用 epoll_wait() 系统调用等待内核告诉它哪些可读可写,然后返回一个集合。程序要么执行 accept() ,要么执行 recvfrom()。
那么kennel是怎么把有事件/消息的 fd
,从第一个放到第二个呢?而且内核还不用遍历这个些个 fd
.
其实把 fd
放入 第一个空间之后, 内核根本就没有忙着调用 CPU 去遍历它, client 发送消息(连接或数据)之后,先进到 网卡(网卡 会在内存空间开辟一个空间 DMA
当网卡读到数据会把数据放进 DMA
,这样可以加速 CPU 执行到程序的时候想跑的数据内存已经有了)网卡会触发中断,发送中断信号(中断号在kennel会有一个对应的回调),触发回调(哦,原来是网卡上的事),就去看 DMA
里面的数据, DMA
里面的数据有了, CPU就从别的程序切回来,切回来之后发现读到的数据有 client 建立连接的事,就可以在DMA
里面发现这个事件,根据内核的epoll
就把东西移到第二个空间交给你的 程序. 所以内核不需要用 CPU 遍历它.
epoll + 非阻塞 socket这样的编程是异步编程,这样编程其实相对比较麻烦。
非阻塞 socket + epoll + 同步编程 = 协程
下图是 openresty 中的一段 lua 代码,它就是协程的理念:
connect 建立 TCP 连接(三次握手),connect的实现中一旦发现建立连接需要等待,它就切换(switch)到其他的 TCP 连接处理。等到我们终于收到了对方发来的 ACK 以后,就激活这段代码(if not ok then)是否连接成功。
这样我们的编程就非常的简单。这就是我们当下用协程处理 TCP 多路复用的常见技术。
小结
不管是 select/poll 还是 epoll,程序都是通过多路复用器,获知了哪些可以操作,然后自己去操作。从 accept 到recvform都需要程序去实现,这种就是同步模型。 (异步模型在 Linux 不好实现)
看看 redis 的 epoll 是怎么玩的 IO 模型:
redis IO 模型
strace 追踪 redis<6.0 版本>
redis 多路复用同步非阻塞IO模型
追踪 redis源码 src/redis-server
$ mkdir -p ~/strace_log/redis
$ cd /[redis源码]/src
$ strace -ff -o ~/strace/redis ./redis-server
可以看到redis server 已启动,PID: 12964。查看 netstat,已监听 6379
端口:
$ netstat -natp
查看 redis server 运行时都有哪些文件描述符:
$ cd /proc/12964/fd
查看 strace 追踪到的日志文件:
$ cd ~/strace_log/redis
$ ll
total 173012
-rw-r--r-- 1 root root 177123511 Nov 29 18:43 log.12964
-rw-r--r-- 1 root root 178 Nov 29 14:10 log.12965
-rw-r--r-- 1 root root 178 Nov 29 14:10 log.12966
-rw-r--r-- 1 root root 178 Nov 29 14:10 log.12967
-rw-r--r-- 1 root root 6195 Nov 29 18:43 log.12968
$ vi log.12964
打开之后先找 socket
可以看到:
- 创建连接之前先调用了
epoll_create
系统调用返回表示内存区域的fd5
; - 红区是创建IPV6的监听连接返回
fd6
, 绑定6379
端口,监听fd6
,并将fd6
设为NOBLOCK
非阻塞; - 紫区是创建IPV4的监听连接返回
fd7
, 绑定6379
端口,监听fd7
,并将fd7
设为NOBLOCK
非阻塞; - 黄区将 监听的
fd6
,fd7
,fd3
放入fd5
, 刚才在/proc/12964/fd
目录可以看到fd3
是个管道。
看一下 这几个系统调用的文档:
epoll_create()
$ man 2 epoll_create
注意文档的返回值:RETURN VALUE,返回的 fd
就代表了空间的第一个区域(用来存放fd
)
epoll_ctl()
$ man 2 epoll_ctl
拉到最后你会发现 redis server
一直在轮询 epoll_wait(5,
,即监听是否有连接或消息(连接是事件,发送数据也是事件)。你可以用 tail
查看:
$ tail -f log.12964
现在连接redis:
$ nc localhost 6379
继续看 strace 的追踪日志:
$ vi log.12964
我们可以知道 epoll_wait
返回 1 即有事件,然后用 /^epoll.*1$
查找:
可以看到接受到一个 client 的连接返回 fd8
,并把 fd8
设为非阻塞,然后放入 fd5
(且fd8
仅放入一次就可以了),
然后又开始轮询 epoll_wait
,看有没有读写或建立连接的事件。
再去 fd 目录看下是不是多了一个连接的 fd
:
$ cd /proc/12964/fd
果然多了一个代表客户端连接的 fd8
。我们给 redis 发些消息看看:
$ nc localhost 6379
key *
.
.
set name tom
.
.
get name
.
.
当然你可以继续用 ^epoll_wait.*1$
查找 log.12964
文件。
注意 redis 是非阻塞的,因为它除了要处理 连接 跟读写数据,还要做 LRU, RDB,AOF这些都放在一个线程去做的。这时候就不能被 epoll_wait
阻塞住(要是被阻塞住,它就一辈子做不了LRU, RDB,AOF)。因此它得 clone 出一个线程去给它做 LRU, RDB,AOF 这些事。
redis RDB
$ nc localhost 6379
key *
.
.
set name tom
.
.
get name
.
.
bgsave
bgsave
就是后台录一个 RDB 快照
可以看到录 RDB 的进程的 PID:5379。去看追踪文件:
$ cd ~/strace/redis/
$ ll
total 211040
-rw-r--r-- 1 root root 216057906 Nov 29 19:43 log.12964
-rw-r--r-- 1 root root 178 Nov 29 14:10 log.12965
-rw-r--r-- 1 root root 178 Nov 29 14:10 log.12966
-rw-r--r-- 1 root root 178 Nov 29 14:10 log.12967
-rw-r--r-- 1 root root 8596 Nov 29 19:41 log.12968
-rw-r--r-- 1 root root 2840 Nov 29 19:41 log.5379
可以发现多了一个录RDB的子进程的追踪文件 log.5379
。我们看一下 log.12964
搜那个多出来的子进程PID: 5379
如果是阻塞在 epoll_wait
那么根本就没有后面的 clone
了。这是我们手动发了一个 bgsave
之后才克隆出来的。redis server 会读配置文件,然后有一个轮询机制去处理生成 RDB 文件.
可以发现 redis 的 IO 如下图所示:
strace 追踪 nginx
nginx 多路复用阻塞IO模型
mkdir ~/strace-nginx
$ strace -ff -o ~/strace-nginx/nginx ../sbin/nginx
$ cd ~/strace-nginx
$ ls
$ vim nginx.[nginx_PID]
发现创建一个子进程后就挂在那了
$ ps -ef | grep nginx
$ vi nginx.[nginx_worker_PID]
你可以看到 epoll_wait 阻塞在那了
Kafka I/O 模型
参考文档
-
服务端经典的C10k问题(译): https://zhuanlan.zhihu.com/p/61785349
-
B站架构师讲解:TCP,NIO,epoll:https://www.bilibili.com/video/BV1zT4y1L7eK?p=1