工欲善其事必先利其器:学会使用各种工具
其实在平常使用套接字开发和测试过程中,我们总会碰到这样或那样的问题。学会对这些问题进行诊断和分析,其实需要不断地积累经验。而 Linux 平台下提供的各种网络工具,则为我们进行诊断分析提供了很好的帮助。在这一讲里,我将会选择几个重点的工具逐一介绍。
必备工具: ping
这个命令我想大家都不陌生,“ping”这个命名来自于声呐探测,在网络上用来完成对网络连通性的探测,这个命名可以说是恰如其分了。
|
$ ping www.sina.com.cn
|
|
PING www.sina.com.cn (202.102.94.124) 56(84) bytes of data.
|
|
64 bytes from www.sina.com.cn (202.102.94.124): icmp_seq=1 ttl=63 time=8.64 ms
|
|
64 bytes from www.sina.com.cn (202.102.94.124): icmp_seq=2 ttl=63 time=11.3 ms
|
|
64 bytes from www.sina.com.cn (202.102.94.124): icmp_seq=3 ttl=63 time=8.66 ms
|
|
64 bytes from www.sina.com.cn (202.102.94.124): icmp_seq=4 ttl=63 time=13.7 ms
|
|
64 bytes from www.sina.com.cn (202.102.94.124): icmp_seq=5 ttl=63 time=8.22 ms
|
|
64 bytes from www.sina.com.cn (202.102.94.124): icmp_seq=6 ttl=63 time=7.99 ms
|
|
^C
|
|
--- www.sina.com.cn ping statistics ---
|
|
6 packets transmitted, 6 received, 0% packet loss, time 5006ms
|
|
rtt min/avg/max/mdev = 7.997/9.782/13.795/2.112 ms
|
在上面的例子中,我使用 ping 命令探测了和新浪网的网络连通性。可以看到,每次显示是按照 sequence 序列号排序显示的,一并显示的,也包括 TTL(time to live),反映了两个 IP 地址之间传输的时间。最后还显示了 ping 命令的统计信息,如最小时间、平均时间等。
我们需要经常和 Linux 下的 ping 命令打交道,那么 ping 命令的原理到底是什么呢?它是基于 TCP 还是 UDP 开发的?
都不是。
其实,ping 是基于一种叫做 ICMP 的协议开发的,ICMP 又是一种基于 IP 协议的控制协议,翻译为网际控制协议,其报文格式如下图:

ICMP 在 IP 报文后加入了新的内容,这些内容包括:
- 类型:即 ICMP 的类型, 其中 ping 的请求类型为 0,应答为 8。
- 代码:进一步划分 ICMP 的类型, 用来查找产生错误的原因。
- 校验和:用于检查错误的数据。
- 标识符:通过标识符来确认是谁发送的控制协议,可以是进程 ID。
- 序列号:唯一确定的一个报文,前面 ping 名字执行后显示的 icmp_seq 就是这个值。
当我们发起 ping 命令时,ping 程序实际上会组装成如图的一个 IP 报文。报文的目的地址为 ping 的目标地址,源地址就是发送 ping 命令时的主机地址,同时按照 ICMP 报文格式填上数据,在可选数据上可以填上发送时的时间戳。
IP 报文通过 ARP 协议,源地址和目的地址被翻译成 MAC 地址,经过数据链路层后,报文被传输出去。当报文到达目的地址之后,目的地址所在的主机也按照 ICMP 协议进行应答。之所以叫做协议,是因为双方都会遵守这个报文格式,并且也会按照格式进行发送 - 应答。
应答数据到达源地址之后,ping 命令可以通过再次解析 ICMP 报文,对比序列号,计算时间戳等来完成每个发送 - 应答的显示,最终显示的格式就像前面的例子中展示的一样。
可以说,ICMP 协议为我们侦测网络问题提供了非常好的支持。另外一种对路由的检测命令 Traceroute 也是通过 ICMP 协议来完成的,这里就不展开讲了。
基本命令: ifconfig
很多熟悉 Windows 的同学都知道 Windows 有一个 ipconfig 命令,用来显示当前的网络设备列表。事实上,Linux 有一个对应的命令叫做 ifconfig,也用来显示当前系统中的所有网络设备,通俗一点的说,就是网卡列表。
|
vagrant@ubuntu-xenial-01:~$ ifconfig
|
|
cni0 Link encap:Ethernet HWaddr 0a:58:0a:f4:00:01
|
|
inet addr:10.244.0.1 Bcast:0.0.0.0 Mask:255.255.255.0
|
|
inet6 addr: fe80::401:b4ff:fe51:bcf9/64 Scope:Link
|
|
UP BROADCAST RUNNING MULTICAST MTU:1450 Metric:1
|
|
RX packets:2133 errors:0 dropped:0 overruns:0 frame:0
|
|
TX packets:2216 errors:0 dropped:0 overruns:0 carrier:0
|
|
collisions:0 txqueuelen:1000
|
|
RX bytes:139381 (139.3 KB) TX bytes:853302 (853.3 KB)
|
|
|
|
|
|
docker0 Link encap:Ethernet HWaddr 02:42:93:0f:f7:11
|
|
inet addr:172.17.0.1 Bcast:0.0.0.0 Mask:255.255.0.0
|
|
inet6 addr: fe80::42:93ff:fe0f:f711/64 Scope:Link
|
|
UP BROADCAST RUNNING MULTICAST MTU:1500 Metric:1
|
|
RX packets:653 errors:0 dropped:0 overruns:0 frame:0
|
|
TX packets:685 errors:0 dropped:0 overruns:0 carrier:0
|
|
collisions:0 txqueuelen:0
|
|
RX bytes:49542 (49.5 KB) TX bytes:430826 (430.8 KB)
|
|
|
|
|
|
enp0s3 Link encap:Ethernet HWaddr 02:54:ad:ea:60:2e
|
|
inet addr:10.0.2.15 Bcast:10.0.2.255 Mask:255.255.255.0
|
|
inet6 addr: fe80::54:adff:feea:602e/64 Scope:Link
|
|
UP BROADCAST RUNNING MULTICAST MTU:1500 Metric:1
|
|
RX packets:7951 errors:0 dropped:0 overruns:0 frame:0
|
|
TX packets:4123 errors:0 dropped:0 overruns:0 carrier:0
|
|
collisions:0 txqueuelen:1000
|
|
RX bytes:5081047 (5.0 MB) TX bytes:385600 (385.6 KB)
|
我稍微解释一下这里面显示的数据。
|
Link encap:Ethernet HWaddr 02:54:ad:ea:60:2e
|
上面这段表明这是一个以太网设备,MAC 地址为 02:54:ad:ea:60:2e。
|
inet addr:10.0.2.15 Bcast:10.0.2.255 Mask:255.255.255.0
|
|
inet6 addr: fe80::54:adff:feea:602e/64 Scope:Link
|
这里显示的是网卡的 IPv4 和 IPv6 地址,其中 IPv4 还显示了该网络的子网掩码以及广播地址。
在每个 IPv4 子网中,有一个特殊地址被保留作为子网广播地址,比如这里的 10.0.2.255 就是这个子网的广播地址。当向这个地址发送请求时,就会向以太网网络上的一组主机发送请求。
通常来说,这种被称作广播(broadcast)的技术,是用 UDP 来实现的。
|
UP BROADCAST RUNNING MULTICAST MTU:1500 Metric:1
|
这里显示的是网卡的状态,MTU 是最大传输单元的意思,表示的是链路层包的大小。1500 表示的是字节大小。
Metric 大家可能不知道是干啥用的,这里解释下,Linux 在一台主机上可以有多个网卡设备,很可能有这么一种情况,多个网卡可以路由到目的地。一个简单的例子是在同时有无线网卡和有线网卡的情况下,网络连接是从哪一个网卡设备上出去的?Metric 就是用来确定多块网卡的优先级的,数值越小,优先级越高,1 为最高级。
|
RX packets:7951 errors:0 dropped:0 overruns:0 frame:0
|
|
TX packets:4123 errors:0 dropped:0 overruns:0 carrier:0
|
|
collisions:0 txqueuelen:1000
|
|
RX bytes:5081047 (5.0 MB) TX bytes:385600 (385.6 KB)
|
netstat 和 lsof:对网络状况了如指掌
在平时的工作中,我们最常碰到的问题就是某某进程对应的网络状况如何?是不是连接被打爆了?还是有大量的 TIME_WAIT 连接?
netstat 可以帮助我们了解当前的网络连接状况,比如我想知道当前所有的连接详情,就可以使用下面这行命令:
|
netstat -alepn
|
可能的结果为:

netstat 会把所有 IPv4 形态的 TCP,IPV6 形态的 TCP、UDP 以及 UNIX 域的套接字都显示出来。
对于 TCP 类型来说,最大的好处是可以清楚地看到一条 TCP 连接的四元组(源地址、源端口、目的地地址和目的端口)。
例如这里的一条信息:
|
tcp 0 0 127.0.0.1:2379 127.0.0.1:52464 ESTABLISHED 0 27710 3496/etcd
|
它表达的意思是本地 127.0.0.1 的端口 52464 连上本地 127.0.0.1 的端口 2379,状态为 ESTABLISHED,本地进程为 etcd,进程为 3496。
这在实战分析的时候非常有用,比如你可以很方便地知道,在某个时候是不是有很多 TIME_WAIT 的 TCP 连接,导致端口号被占用光,以致新的连接分配不了。
当然,我们也可以只对 UNIX 套接字进行筛查。
|
netstat Socket -x -alepn
|

UNIX 套接字的结果稍有不同,最关键的信息是 Path,这个信息显示了本地套接字监听的文件路径,比如这条:
|
unix 3 [ ] STREAM CONNECTED 23209 1400/dockerd /var/run/docker.sock
|
这其实就是大名鼎鼎的 Docker 在本地套接字的监听路径。/var/run/docker.sock 是本地套接字监听地址,dockerd 是进程名称,1400 是进程号。
netstat 命令可以选择的参数非常之多,这里只关注了几个简单的场景,你可以通过帮助命令或者查阅文档获得更多的信息。
lsof 的常见用途之一是帮助我们找出在指定的 IP 地址或者端口上打开套接字的进程,而 netstat 则告诉我们 IP 地址和端口使用的情况,以及各个 TCP 连接的状态。Isof 和 netstst 可以结合起来一起使用。
比如说,我们可以通过 lsof 查看到底是谁打开了这个文件:
|
lsof /var/run/docker.sock
|
下面这张图显示了是 dockerd 打开了这个本地文件套接字:

lsof 还有一个非常常见的用途。如果我们启动了一个服务器程序,发现这个服务器需要绑定的端口地址已经被占用,内核报出“该地址已在使用”的出错信息,我们可以使用 lsof 找出正在使用该端口的那个进程。比如下面这个代码,就帮我们找到了使用 8080 端口的那个进程,从而帮助我们定位问题。
|
lsof -i :8080
|
抓包利器: tcpdump
tcpdump 这样的抓包工具对于网络编程而言是非常有用的,特别是在一些”山重水复疑无路“的情形下,通过 tcpdump 这样的抓包工具,往往可以达到”柳岸花明又一村“的效果。
tcpdump 具有非常强大的过滤和匹配功能。
比如说指定网卡:
|
tcpdump -i eth0
|
再比如说指定来源:
|
tcpdump src host hostname
|
我们再来一个复杂一点的例子。这里抓的包是 TCP,且端口是 80,包来自 IP 地址为 192.168.1.25 的主机地址。
|
tcpdump 'tcp and port 80 and src host 192.168.1.25'
|
如果我们对 TCP 协议非常熟悉,还可以写出这样的 tcpdump 命令:
|
tcpdump 'tcp and port 80 and tcp[13:1]&2 != 0'
|
这里 tcp[13:1] 表示的是 TCP 头部开始处偏移为 13 的字节,如果这个值为 2,说明设置了 SYN 分节,当然,我们也可以设置成其他值来获取希望类型的分节。
tcpdump 在开启抓包的时候,会自动创建一个类型为 AF_PACKET 的网络套接口,并向系统内核注册。当网卡接收到一个网络报文之后,它会遍历系统中所有已经被注册的网络协议,包括其中已经注册了的 AF_PACKET 网络协议。系统内核接下来就会将网卡收到的报文发送给该协议的回调函数进行一次处理,回调函数可以把接收到的报文完完整整地复制一份,假装是自己接收到的报文,然后交给 tcpdump 程序,进行各种条件的过滤和判断,再对报文进行解析输出。
下面这张图显示的是 tcpdump 的输出格式:

首先我们看到的是时间戳,之后类似 192.168.33.11.41388 > 192.168.33.11.6443 这样的,显示的是源地址(192.168.33.11.41388)到目的地址(192.168.33.11.6443);然后 Flags [ ] 是包的标志,[P] 表示是数据推送,比较常见的包格式如下:
- [S]:SYN,表示开始连接
- [.]:没有标记,一般是确认
- [P]:PSH,表示数据推送
- [F]:FIN,表示结束连接
- [R] :RST,表示重启连接
我们可以看到最后有几个数据,它们代表的含义如下:
- seq:包序号,就是 TCP 的确认分组
- cksum:校验码
- win:滑动窗口大小
- length:承载的数据(payload)长度 length,如果没有数据则为 0
此外,tcpdump 还可以对每条 TCP 报文的细节进行显示,让我们可以看到每条报文的详细字节信息。这在对报文进行排查的时候很有用。
小结
本章我讲述了一些常见的网络诊断工具,这些工具需要你了解之后活学活用。用好它们,对加深网络编程的理解,以及对问题情况进行排查等都有非常大的帮助。
我再来总结一下这几个命令的作用:
- ping 可以用来帮助我们进行网络连通性的探测。
- ifconfig,用来显示当前系统中的所有网络设备。
- netstat 和 lsof 可以查看活动的连接状况。
- tcpdump 可以对各种奇怪的环境进行抓包,进而帮我们了解报文,排查问题。
TIME_WAIT:隐藏在细节下的魔鬼
在前面的基础篇里,我们了解了 TCP 四次挥手,在四次挥手的过程中,发起连接断开的一方会有一段时间处于 TIME_WAIT 的状态,你知道 TIME_WAIT 是用来做什么的么?在面试和实战中,TIME_WAIT 相关的问题始终是绕不过去的一道难题。下面就请跟随我,一起找出隐藏在细节下的魔鬼吧。
TIME_WAIT 发生的场景
让我们先从一例线上故障说起。在一次升级线上应用服务之后,我们发现该服务的可用性变得时好时坏,一段时间可以对外提供服务,一段时间突然又不可以,大家都百思不得其解。运维同学登录到服务所在的主机上,使用 netstat 命令查看后才发现,主机上有成千上万处于 TIME_WAIT 状态的连接。
经过层层剖析后,我们发现罪魁祸首就是 TIME_WAIT。为什么呢?我们这个应用服务需要通过发起 TCP 连接对外提供服务。每个连接会占用一个本地端口,当在高并发的情况下,TIME_WAIT 状态的连接过多,多到把本机可用的端口耗尽,应用服务对外表现的症状,就是不能正常工作了。当过了一段时间之后,处于 TIME_WAIT 的连接被系统回收并关闭后,释放出本地端口可供使用,应用服务对外表现为,可以正常工作。这样周而复始,便会出现了一会儿不可以,过一两分钟又可以正常工作的现象。
那么为什么会产生这么多的 TIME_WAIT 连接呢?
这要从 TCP 的四次挥手说起。我在文稿中放了这样一张图。

TCP 连接终止时,主机 1 先发送 FIN 报文,主机 2 进入 CLOSE_WAIT 状态,并发送一个 ACK 应答,同时,主机 2 通过 read 调用获得 EOF,并将此结果通知应用程序进行主动关闭操作,发送 FIN 报文。主机 1 在接收到 FIN 报文后发送 ACK 应答,此时主机 1 进入 TIME_WAIT 状态。
主机 1 在 TIME_WAIT 停留持续时间是固定的,是最长分节生命期 MSL(maximum segment lifetime)的两倍,一般称之为 2MSL。和大多数 BSD 派生的系统一样,Linux 系统里有一个硬编码的字段,名称为TCP_TIMEWAIT_LEN,其值为 60 秒。也就是说,Linux 系统停留在 TIME_WAIT 的时间为固定的 60 秒。
|
#define TCP_TIMEWAIT_LEN (60*HZ) /* how long to wait to destroy TIME- WAIT state, about 60 seconds */
|
过了这个时间之后,主机 1 就进入 CLOSED 状态。为什么是这个时间呢?你可以先想一想,稍后我会给出解答。
你一定要记住一点,只有发起连接终止的一方会进入 TIME_WAIT 状态。这一点面试的时候经常会被问到。
TIME_WAIT 的作用
你可能会问,为什么不直接进入 CLOSED 状态,而要停留在 TIME_WAIT 这个状态?
这要从两个方面来说。
首先,这样做是为了确保最后的 ACK 能让被动关闭方接收,从而帮助其正常关闭。
TCP 在设计的时候,做了充分的容错性设计,比如,TCP 假设报文会出错,需要重传。在这里,如果图中主机 1 的 ACK 报文没有传输成功,那么主机 2 就会重新发送 FIN 报文。
如果主机 1 没有维护 TIME_WAIT 状态,而直接进入 CLOSED 状态,它就失去了当前状态的上下文,只能回复一个 RST 操作,从而导致被动关闭方出现错误。
现在主机 1 知道自己处于 TIME_WAIT 的状态,就可以在接收到 FIN 报文之后,重新发出一个 ACK 报文,使得主机 2 可以进入正常的 CLOSED 状态。
第二个理由和连接“化身”和报文迷走有关系,为了让旧连接的重复分节在网络中自然消失。
我们知道,在网络中,经常会发生报文经过一段时间才能到达目的地的情况,产生的原因是多种多样的,如路由器重启,链路突然出现故障等。如果迷走报文到达时,发现 TCP 连接四元组(源 IP,源端口,目的 IP,目的端口)所代表的连接不复存在,那么很简单,这个报文自然丢弃。
我们考虑这样一个场景,在原连接中断后,又重新创建了一个原连接的“化身”,说是化身其实是因为这个连接和原先的连接四元组完全相同,如果迷失报文经过一段时间也到达,那么这个报文会被误认为是连接“化身”的一个 TCP 分节,这样就会对 TCP 通信产生影响。

所以,TCP 就设计出了这么一个机制,经过 2MSL 这个时间,足以让两个方向上的分组都被丢弃,使得原来连接的分组在网络中都自然消失,再出现的分组一定都是新化身所产生的。
划重点,2MSL 的时间是从主机 1 接收到 FIN 后发送 ACK 开始计时的;如果在 TIME_WAIT 时间内,因为主机 1 的 ACK 没有传输到主机 2,主机 1 又接收到了主机 2 重发的 FIN 报文,那么 2MSL 时间将重新计时。道理很简单,因为 2MSL 的时间,目的是为了让旧连接的所有报文都能自然消亡,现在主机 1 重新发送了 ACK 报文,自然需要重新计时,以便防止这个 ACK 报文对新可能的连接化身造成干扰。
TIME_WAIT 的危害
过多的 TIME_WAIT 的主要危害有两种。
第一是内存资源占用,这个目前看来不是太严重,基本可以忽略。
第二是对端口资源的占用,一个 TCP 连接至少消耗一个本地端口。要知道,端口资源也是有限的,一般可以开启的端口为 32768~61000 ,也可以通过net.ipv4.ip_local_port_range指定,如果 TIME_WAIT 状态过多,会导致无法创建新连接。这个也是我们在一开始讲到的那个例子。
如何优化 TIME_WAIT?
在高并发的情况下,如果我们想对 TIME_WAIT 做一些优化,来解决我们一开始提到的例子,该如何办呢?
net.ipv4.tcp_max_tw_buckets
一个暴力的方法是通过 sysctl 命令,将系统值调小。这个值默认为 18000,当系统中处于 TIME_WAIT 的连接一旦超过这个值时,系统就会将所有的 TIME_WAIT 连接状态重置,并且只打印出警告信息。这个方法过于暴力,而且治标不治本,带来的问题远比解决的问题多,不推荐使用。
调低 TCP_TIMEWAIT_LEN,重新编译系统
这个方法是一个不错的方法,缺点是需要“一点”内核方面的知识,能够重新编译内核。我想这个不是大多数人能接受的方式。
SO_LINGER 的设置
英文单词“linger”的意思为停留,我们可以通过设置套接字选项,来设置调用 close 或者 shutdown 关闭连接时的行为。
|
int setsockopt(int sockfd, int level, int optname, const void *optval,
|
|
socklen_t optlen);
|
|
struct linger {
|
|
int l_onoff; /* 0=off, nonzero=on */
|
|
int l_linger; /* linger time, POSIX specifies units as seconds */
|
|
}
|
设置 linger 参数有几种可能:
- 如果
l_onoff为 0,那么关闭本选项。l_linger的值被忽略,这对应了默认行为,close 或 shutdown 立即返回。如果在套接字发送缓冲区中有数据残留,系统会将试着把这些数据发送出去。 - 如果
l_onoff为非 0, 且l_linger值也为 0,那么调用 close 后,会立该发送一个 RST 标志给对端,该 TCP 连接将跳过四次挥手,也就跳过了 TIME_WAIT 状态,直接关闭。这种关闭的方式称为“强行关闭”。 在这种情况下,排队数据不会被发送,被动关闭方也不知道对端已经彻底断开。只有当被动关闭方正阻塞在recv()调用上时,接受到 RST 时,会立刻得到一个“connet reset by peer”的异常。
|
struct linger so_linger;
|
|
so_linger.l_onoff = 1;
|
|
so_linger.l_linger = 0;
|
|
setsockopt(s,SOL_SOCKET,SO_LINGER, &so_linger,sizeof(so_linger));
|
- 如果
l_onoff为非 0, 且l_linger的值也非 0,那么调用 close 后,调用 close 的线程就将阻塞,直到数据被发送出去,或者设置的l_linger计时时间到。
第二种可能为跨越 TIME_WAIT 状态提供了一个可能,不过是一个非常危险的行为,不值得提倡。
net.ipv4.tcp_tw_reuse:更安全的设置
那么 Linux 有没有提供更安全的选择呢?
当然有。这就是net.ipv4.tcp_tw_reuse选项。
Linux 系统对于net.ipv4.tcp_tw_reuse的解释如下:
|
Allow to reuse TIME-WAIT sockets for new connections when it is safe from protocol viewpoint. Default value is 0.It should not be changed without advice/request of technical experts.
|
这段话的大意是从协议角度理解如果是安全可控的,可以复用处于 TIME_WAIT 的套接字为新的连接所用。
那么什么是协议角度理解的安全可控呢?主要有两点:
- 只适用于连接发起方(C/S 模型中的客户端);
- 对应的 TIME_WAIT 状态的连接创建时间超过 1 秒才可以被复用。
使用这个选项,还有一个前提,需要打开对 TCP 时间戳的支持,即net.ipv4.tcp_timestamps=1(默认即为 1)。
要知道,TCP 协议也在与时俱进,RFC 1323 中实现了 TCP 拓展规范,以便保证 TCP 的高可用,并引入了新的 TCP 选项,两个 4 字节的时间戳字段,用于记录 TCP 发送方的当前时间戳和从对端接收到的最新时间戳。由于引入了时间戳,我们在前面提到的 2MSL 问题就不复存在了,因为重复的数据包会因为时间戳过期被自然丢弃。
总结
在今天的内容里,我讲了 TCP 的四次挥手,重点对 TIME_WAIT 的产生、作用以及优化进行了讲解,你需要记住以下三点:
- TIME_WAIT 的引入是为了让 TCP 报文得以自然消失,同时为了让被动关闭方能够正常关闭;
- 不要试图使用
SO_LINGER设置套接字选项,跳过 TIME_WAIT; - 现代 Linux 系统引入了更安全可控的方案,可以帮助我们尽可能地复用 TIME_WAIT 状态的连接。
我们知道,一个 TCP 连接需要经过三次握手进入数据传输阶段,最后来到连接关闭阶段。在最后的连接关闭阶段,我们需要重点关注的是“半连接”状态。
因为 TCP 是双向的,这里说的方向,指的是数据流的写入 - 读出的方向。
比如客户端到服务器端的方向,指的是客户端通过套接字接口,向服务器端发送 TCP 报文;而服务器端到客户端方向则是另一个传输方向。在绝大数情况下,TCP 连接都是先关闭一个方向,此时另外一个方向还是可以正常进行数据传输。
举个例子,客户端主动发起连接的中断,将自己到服务器端的数据流方向关闭,此时,客户端不再往服务器端写入数据,服务器端读完客户端数据后就不会再有新的报文到达。但这并不意味着,TCP 连接已经完全关闭,很有可能的是,服务器端正在对客户端的最后报文进行处理,比如去访问数据库,存入一些数据;或者是计算出某个客户端需要的值,当完成这些操作之后,服务器端把结果通过套接字写给客户端,我们说这个套接字的状态此时是“半关闭”的。最后,服务器端才有条不紊地关闭剩下的半个连接,结束这一段 TCP 连接的使命。
当然,我这里描述的,是服务器端“优雅”地关闭了连接。如果服务器端处理不好,就会导致最后的关闭过程是“粗暴”的,达不到我们上面描述的“优雅”关闭的目标,形成的后果,很可能是服务器端处理完的信息没办法正常传送给客户端,破坏了用户侧的使用场景。
接下来我们就来看看关闭连接时,都有哪些方式呢?
close 函数
首先,我们来看最常见的 close 函数:
|
int close(int sockfd)
|
这个函数很简单,对已连接的套接字执行 close 操作就可以,若成功则为 0,若出错则为 -1。
这个函数会对套接字引用计数减一,一旦发现套接字引用计数到 0,就会对套接字进行彻底释放,并且会关闭TCP 两个方向的数据流。
套接字引用计数是什么意思呢?因为套接字可以被多个进程共享,你可以理解为我们给每个套接字都设置了一个积分,如果我们通过 fork 的方式产生子进程,套接字就会积分 +1, 如果我们调用一次 close 函数,套接字积分就会 -1。这就是套接字引用计数的含义。
close 函数具体是如何关闭两个方向的数据流呢?
在输入方向,系统内核会将该套接字设置为不可读,任何读操作都会返回异常。
在输出方向,系统内核尝试将发送缓冲区的数据发送给对端,并最后向对端发送一个 FIN 报文,接下来如果再对该套接字进行写操作会返回异常。
如果对端没有检测到套接字已关闭,还继续发送报文,就会收到一个 RST 报文,告诉对端:“Hi, 我已经关闭了,别再给我发数据了。”
我们会发现,close 函数并不能帮助我们关闭连接的一个方向,那么如何在需要的时候关闭一个方向呢?幸运的是,设计 TCP 协议的人帮我们想好了解决方案,这就是 shutdown 函数。
shutdown 函数
shutdown 函数的原型是这样的:
|
int shutdown(int sockfd, int howto)
|
对已连接的套接字执行 shutdown 操作,若成功则为 0,若出错则为 -1。
howto 是这个函数的设置选项,它的设置有三个主要选项:
- SHUT_RD(0):关闭连接的“读”这个方向,对该套接字进行读操作直接返回 EOF。从数据角度来看,套接字上接收缓冲区已有的数据将被丢弃,如果再有新的数据流到达,会对数据进行 ACK,然后悄悄地丢弃。也就是说,对端还是会接收到 ACK,在这种情况下根本不知道数据已经被丢弃了。
- SHUT_WR(1):关闭连接的“写”这个方向,这就是常被称为”半关闭“的连接。此时,不管套接字引用计数的值是多少,都会直接关闭连接的写方向。套接字上发送缓冲区已有的数据将被立即发送出去,并发送一个 FIN 报文给对端。应用程序如果对该套接字进行写操作会报错。
- SHUT_RDWR(2):相当于 SHUT_RD 和 SHUT_WR 操作各一次,关闭套接字的读和写两个方向。
讲到这里,不知道你是不是有和我当初一样的困惑,使用 SHUT_RDWR 来调用 shutdown 不是和 close 基本一样吗,都是关闭连接的读和写两个方向。
其实,这两个还是有差别的。
第一个差别:close 会关闭连接,并释放所有连接对应的资源,而 shutdown 并不会释放掉套接字和所有的资源。
第二个差别:close 存在引用计数的概念,并不一定导致该套接字不可用;shutdown 则不管引用计数,直接使得该套接字不可用,如果有别的进程企图使用该套接字,将会受到影响。
第三个差别:close 的引用计数导致不一定会发出 FIN 结束报文,而 shutdown 则总是会发出 FIN 结束报文,这在我们打算关闭连接通知对端的时候,是非常重要的。
体会 close 和 shutdown 的差别
下面,我们通过构建一组客户端和服务器程序,来进行 close 和 shutdown 的实验。
客户端程序,从标准输入不断接收用户输入,把输入的字符串通过套接字发送给服务器端,同时,将服务器端的应答显示到标准输出上。
如果用户输入了“close”,则会调用 close 函数关闭连接,休眠一段时间,等待服务器端处理后退出;如果用户输入了“shutdown”,调用 shutdown 函数关闭连接的写方向,注意我们不会直接退出,而是会继续等待服务器端的应答,直到服务器端完成自己的操作,在另一个方向上完成关闭。
在这里,我们会第一次接触到 select 多路复用,这里不展开讲,你只需要记住,使用 select 使得我们可以同时完成对连接套接字和标准输入两个 I/O 对象的处理。
|
# include "lib/common.h"
|
|
# define MAXLINE 4096
|
|
|
|
int main(int argc, char **argv) {
|
|
if (argc != 2) {
|
|
error(1, 0, "usage: graceclient <IPaddress>");
|
|
}
|
|
|
|
int socket_fd;
|
|
socket_fd = socket(AF_INET, SOCK_STREAM, 0);
|
|
|
|
struct sockaddr_in server_addr;
|
|
bzero(&server_addr, sizeof(server_addr));
|
|
server_addr.sin_family = AF_INET;
|
|
server_addr.sin_port = htons(SERV_PORT);
|
|
inet_pton(AF_INET, argv[1], &server_addr.sin_addr);
|
|
|
|
socklen_t server_len = sizeof(server_addr);
|
|
int connect_rt = connect(socket_fd, (struct sockaddr *) &server_addr, server_len);
|
|
if (connect_rt < 0) {
|
|
error(1, errno, "connect failed ");
|
|
}
|
|
|
|
char send_line[MAXLINE], recv_line[MAXLINE + 1];
|
|
int n;
|
|
|
|
fd_set readmask;
|
|
fd_set allreads;
|
|
|
|
FD_ZERO(&allreads);
|
|
FD_SET(0, &allreads);
|
|
FD_SET(socket_fd, &allreads);
|
|
for (;;) {
|
|
readmask = allreads;
|
|
int rc = select(socket_fd + 1, &readmask, NULL, NULL, NULL);
|
|
if (rc <= 0)
|
|
error(1, errno, "select failed");
|
|
if (FD_ISSET(socket_fd, &readmask)) {
|
|
n = read(socket_fd, recv_line, MAXLINE);
|
|
if (n < 0) {
|
|
error(1, errno, "read error");
|
|
} else if (n == 0) {
|
|
error(1, 0, "server terminated \n");
|
|
}
|
|
recv_line[n] = 0;
|
|
fputs(recv_line, stdout);
|
|
fputs("\n", stdout);
|
|
}
|
|
if (FD_ISSET(0, &readmask)) {
|
|
if (fgets(send_line, MAXLINE, stdin) != NULL) {
|
|
if (strncmp(send_line, "shutdown", 8) == 0) {
|
|
FD_CLR(0, &allreads);
|
|
if (shutdown(socket_fd, 1)) {
|
|
error(1, errno, "shutdown failed");
|
|
}
|
|
} else if (strncmp(send_line, "close", 5) == 0) {
|
|
FD_CLR(0, &allreads);
|
|
if (close(socket_fd)) {
|
|
error(1, errno, "close failed");
|
|
}
|
|
sleep(6);
|
|
exit(0);
|
|
} else {
|
|
int i = strlen(send_line);
|
|
if (send_line[i - 1] == '\n') {
|
|
send_line[i - 1] = 0;
|
|
}
|
|
|
|
printf("now sending %s\n", send_line);
|
|
size_t rt = write(socket_fd, send_line, strlen(send_line));
|
|
if (rt < 0) {
|
|
error(1, errno, "write failed ");
|
|
}
|
|
printf("send bytes: %zu \n", rt);
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
}
|
我对这个程序的细节展开解释一下:
第一部分是套接字的创建和 select 初始工作:
- 9-10 行创建了一个 TCP 套接字;
- 12-16 行设置了连接的目标服务器 IPv4 地址,绑定到了指定的 IP 和端口;
- 18-22 行使用创建的套接字,向目标 IPv4 地址发起连接请求;
- 30-32 行为使用 select 做准备,初始化描述字集合,这部分我会在后面详细解释,这里就不再深入。
第二部分是程序的主体部分,从 33-80 行, 使用 select 多路复用观测在连接套接字和标准输入上的 I/O 事件,其中:
- 38-48 行:当连接套接字上有数据可读,将数据读入到程序缓冲区中。40-41 行,如果有异常则报错退出;42-43 行如果读到服务器端发送的 EOF 则正常退出。
- 49-77 行:当标准输入上有数据可读,读入后进行判断。如果输入的是“shutdown”,则关闭标准输入的 I/O 事件感知,并调用 shutdown 函数关闭写方向;如果输入的是”close“,则调用 close 函数关闭连接;64-74 行处理正常的输入,将回车符截掉,调用 write 函数,通过套接字将数据发送给服务器端。
服务器端程序稍微简单一点,连接建立之后,打印出接收的字节,并重新格式化后,发送给客户端。
服务器端程序有一点需要注意,那就是对 SIGPIPE 这个信号的处理。后面我会结合程序的结果展开说明。
|
#include "lib/common.h"
|
|
|
|
static int count;
|
|
|
|
static void sig_int(int signo) {
|
|
printf("\nreceived %d datagrams\n", count);
|
|
exit(0);
|
|
}
|
|
|
|
int main(int argc, char **argv) {
|
|
int listenfd;
|
|
listenfd = socket(AF_INET, SOCK_STREAM, 0);
|
|
|
|
struct sockaddr_in server_addr;
|
|
bzero(&server_addr, sizeof(server_addr));
|
|
server_addr.sin_family = AF_INET;
|
|
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
|
|
server_addr.sin_port = htons(SERV_PORT);
|
|
|
|
int rt1 = bind(listenfd, (struct sockaddr *) &server_addr, sizeof(server_addr));
|
|
if (rt1 < 0) {
|
|
error(1, errno, "bind failed ");
|
|
}
|
|
|
|
int rt2 = listen(listenfd, LISTENQ);
|
|
if (rt2 < 0) {
|
|
error(1, errno, "listen failed ");
|
|
}
|
|
|
|
signal(SIGINT, sig_int);
|
|
signal(SIGPIPE, SIG_IGN);
|
|
|
|
int connfd;
|
|
struct sockaddr_in client_addr;
|
|
socklen_t client_len = sizeof(client_addr);
|
|
|
|
if ((connfd = accept(listenfd, (struct sockaddr *) &client_addr, &client_len)) < 0) {
|
|
error(1, errno, "bind failed ");
|
|
}
|
|
|
|
char message[MAXLINE];
|
|
count = 0;
|
|
|
|
for (;;) {
|
|
int n = read(connfd, message, MAXLINE);
|
|
if (n < 0) {
|
|
error(1, errno, "error read");
|
|
} else if (n == 0) {
|
|
error(1, 0, "client closed \n");
|
|
}
|
|
message[n] = 0;
|
|
printf("received %d bytes: %s\n", n, message);
|
|
count++;
|
|
|
|
char send_line[MAXLINE];
|
|
sprintf(send_line, "Hi, %s", message);
|
|
|
|
sleep(5);
|
|
|
|
int write_nc = send(connfd, send_line, strlen(send_line), 0);
|
|
printf("send bytes: %zu \n", write_nc);
|
|
if (write_nc < 0) {
|
|
error(1, errno, "error write");
|
|
}
|
|
}
|
|
}
|
服务器端程序的细节也展开解释一下:
第一部分是套接字和连接创建过程:
- 11-12 行创建了一个 TCP 套接字;
- 14-18 行设置了本地服务器 IPv4 地址,绑定到了 ANY 地址和指定的端口;
- 20-40 行使用创建的套接字,以此执行 bind、listen 和 accept 操作,完成连接建立。
第二部分是程序的主体,通过 read 函数获取客户端传送来的数据流,并回送给客户端:
- 51-52 行显示收到的字符串,在 56 行对原字符串进行重新格式化,之后调用 send 函数将数据发送给客户端。注意,在发送之前,让服务器端程序休眠了 5 秒,以模拟服务器端处理的时间。
我们启动服务器,再启动客户端,依次在标准输入上输入 data1、data2 和 close,观察一段时间后我们看到:
|
$./graceclient 127.0.0.1
|
|
data1
|
|
now sending data1
|
|
send bytes:5
|
|
data2
|
|
now sending data2
|
|
send bytes:5
|
|
Hi,data1
|
|
close
|
|
$./graceserver
|
|
received 5 bytes: data1
|
|
send bytes: 9
|
|
received 5 bytes: data2
|
|
send bytes: 9
|
|
client closed
|
客户端依次发送了 data1 和 data2,服务器端也正常接收到 data1 和 data2。在客户端 close 掉整个连接之后,服务器端接收到 SIGPIPE 信号,直接退出。客户端并没有收到服务器端的应答数据。
我在下面放了一张图,这张图详细解释了客户端和服务器端交互的时序图。因为客户端调用 close 函数关闭了整个连接,当服务器端发送的“Hi, data1”分组到底时,客户端给回送一个 RST 分组;服务器端再次尝试发送“Hi, data2”第二个应答分组时,系统内核通知 SIGPIPE 信号。这是因为,在 RST 的套接字进行写操作,会直接触发 SIGPIPE 信号。
这回知道你的程序莫名奇妙终止的原因了吧。

我们可以像这样注册一个信号处理函数,对 SIGPIPE 信号进行处理,避免程序莫名退出:
|
static void sig_pipe(int signo) {
|
|
printf("\nreceived %d datagrams\n", count);
|
|
exit(0);
|
|
}
|
|
signal(SIGINT, sig_pipe);
|
接下来,再次启动服务器,再启动客户端,依次在标准输入上输入 data1、data2 和 shutdown 函数,观察一段时间后我们看到:
|
$./graceclient 127.0.0.1
|
|
data1
|
|
now sending data1
|
|
send bytes:5
|
|
data2
|
|
now sending data2
|
|
send bytes:5
|
|
shutdown
|
|
Hi, data1
|
|
Hi,data2
|
|
server terminated
|
|
$./graceserver
|
|
received 5 bytes: data1
|
|
send bytes: 9
|
|
received 5 bytes: data2
|
|
send bytes: 9
|
|
client closed
|
和前面的结果不同,服务器端输出了 data1、data2;客户端也输出了“Hi,data1”和“Hi,data2”,客户端和服务器端各自完成了自己的工作后,正常退出。
我们再看下客户端和服务器端交互的时序图。因为客户端调用 shutdown 函数只是关闭连接的一个方向,服务器端到客户端的这个方向还可以继续进行数据的发送和接收,所以“Hi,data1”和“Hi,data2”都可以正常传送;当服务器端读到 EOF 时,立即向客户端发送了 FIN 报文,客户端在 read 函数中感知了 EOF,也进行了正常退出。
总结
在这一讲中,我们讲述了 close 函数关闭连接的方法,使用 close 函数关闭连接有两个需要明确的地方。
- close 函数只是把套接字引用计数减 1,未必会立即关闭连接;
- close 函数如果在套接字引用计数达到 0 时,立即终止读和写两个方向的数据传送。
基于这两个确定,在期望关闭连接其中一个方向时,应该使用 shutdown 函数。
我之前做过一个基于 NATS 消息系统的项目,多个消息的提供者 (pub)和订阅者(sub)都连到 NATS 消息系统,通过这个系统来完成消息的投递和订阅处理。
突然有一天,线上报了一个故障,一个流程不能正常处理。经排查,发现消息正确地投递到了 NATS 服务端,但是消息订阅者没有收到该消息,也没能做出处理,导致流程没能进行下去。
通过观察消息订阅者后发现,消息订阅者到 NATS 服务端的连接虽然显示是“正常”的,但实际上,这个连接已经是无效的了。为什么呢?这是因为 NATS 服务器崩溃过,NATS 服务器和消息订阅者之间的连接中断 FIN 包,由于异常情况,没能够正常到达消息订阅者,这样造成的结果就是消息订阅者一直维护着一个“过时的”连接,不会收到 NATS 服务器发送来的消息。
这个故障的根本原因在于,作为 NATS 服务器的客户端,消息订阅者没有及时对连接的有效性进行检测,这样就造成了问题。
保持对连接有效性的检测,是我们在实战中必须要注意的一个点。
TCP Keep-Alive 选项
很多刚接触 TCP 编程的人会惊讶地发现,在没有数据读写的“静默”的连接上,是没有办法发现 TCP 连接是有效还是无效的。比如客户端突然崩溃,服务器端可能在几天内都维护着一个无用的 TCP 连接。前面提到的例子就是这样的一个场景。
那么有没有办法开启类似的“轮询”机制,让 TCP 告诉我们,连接是不是“活着”的呢?
这就是 TCP 保持活跃机制所要解决的问题。实际上,TCP 有一个保持活跃的机制叫做 Keep-Alive。
这个机制的原理是这样的:
定义一个时间段,在这个时间段内,如果没有任何连接相关的活动,TCP 保活机制会开始作用,每隔一个时间间隔,发送一个探测报文,该探测报文包含的数据非常少,如果连续几个探测报文都没有得到响应,则认为当前的 TCP 连接已经死亡,系统内核将错误信息通知给上层应用程序。
上述的可定义变量,分别被称为保活时间、保活时间间隔和保活探测次数。在 Linux 系统中,这些变量分别对应 sysctl 变量net.ipv4.tcp_keepalive_time、net.ipv4.tcp_keepalive_intvl、 net.ipv4.tcp_keepalve_probes,默认设置是 7200 秒(2 小时)、75 秒和 9 次探测。
如果开启了 TCP 保活,需要考虑以下几种情况:
第一种,对端程序是正常工作的。当 TCP 保活的探测报文发送给对端, 对端会正常响应,这样 TCP 保活时间会被重置,等待下一个 TCP 保活时间的到来。
第二种,对端程序崩溃并重启。当 TCP 保活的探测报文发送给对端后,对端是可以响应的,但由于没有该连接的有效信息,会产生一个 RST 报文,这样很快就会发现 TCP 连接已经被重置。
第三种,是对端程序崩溃,或对端由于其他原因导致报文不可达。当 TCP 保活的探测报文发送给对端后,石沉大海,没有响应,连续几次,达到保活探测次数后,TCP 会报告该 TCP 连接已经死亡。
TCP 保活机制默认是关闭的,当我们选择打开时,可以分别在连接的两个方向上开启,也可以单独在一个方向上开启。如果开启服务器端到客户端的检测,就可以在客户端非正常断连的情况下清除在服务器端保留的“脏数据”;而开启客户端到服务器端的检测,就可以在服务器无响应的情况下,重新发起连接。
为什么 TCP 不提供一个频率很好的保活机制呢?我的理解是早期的网络带宽非常有限,如果提供一个频率很高的保活机制,对有限的带宽是一个比较严重的浪费。
应用层探活
如果使用 TCP 自身的 keep-Alive 机制,在 Linux 系统中,最少需要经过 2 小时 11 分 15 秒才可以发现一个“死亡”连接。这个时间是怎么计算出来的呢?其实是通过 2 小时,加上 75 秒乘以 9 的总和。实际上,对很多对时延要求敏感的系统中,这个时间间隔是不可接受的。
所以,必须在应用程序这一层来寻找更好的解决方案。
我们可以通过在应用程序中模拟 TCP Keep-Alive 机制,来完成在应用层的连接探活。
我们可以设计一个 PING-PONG 的机制,需要保活的一方,比如客户端,在保活时间达到后,发起对连接的 PING 操作,如果服务器端对 PING 操作有回应,则重新设置保活时间,否则对探测次数进行计数,如果最终探测次数达到了保活探测次数预先设置的值之后,则认为连接已经无效。
这里有两个比较关键的点:
第一个是需要使用定时器,这可以通过使用 I/O 复用自身的机制来实现;第二个是需要设计一个 PING-PONG 的协议。
下面我们尝试来完成这样的一个设计。
消息格式设计
我们的程序是客户端来发起保活,为此定义了一个消息对象。你可以在文稿中看到这个消息对象,这个消息对象是一个结构体,前 4 个字节标识了消息类型,为了简单,这里设计了MSG_PING、MSG_PONG、MSG_TYPE 1和MSG_TYPE 2四种消息类型。
|
typedef struct {
|
|
u_int32_t type;
|
|
char data[1024];
|
|
} messageObject;
|
|
|
|
#define MSG_PING 1
|
|
#define MSG_PONG 2
|
|
#define MSG_TYPE1 11
|
|
#define MSG_TYPE2 21
|
客户端程序设计
客户端完全模拟 TCP Keep-Alive 的机制,在保活时间达到后,探活次数增加 1,同时向服务器端发送 PING 格式的消息,此后以预设的保活时间间隔,不断地向服务器端发送 PING 格式的消息。如果能收到服务器端的应答,则结束保活,将保活时间置为 0。
这里我们使用 select I/O 复用函数自带的定时器,select 函数将在后面详细介绍。
|
#include "lib/common.h"
|
|
#include "message_objecte.h"
|
|
|
|
#define MAXLINE 4096
|
|
#define KEEP_ALIVE_TIME 10
|
|
#define KEEP_ALIVE_INTERVAL 3
|
|
#define KEEP_ALIVE_PROBETIMES 3
|
|
|
|
|
|
int main(int argc, char **argv) {
|
|
if (argc != 2) {
|
|
error(1, 0, "usage: tcpclient <IPaddress>");
|
|
}
|
|
|
|
int socket_fd;
|
|
socket_fd = socket(AF_INET, SOCK_STREAM, 0);
|
|
|
|
struct sockaddr_in server_addr;
|
|
bzero(&server_addr, sizeof(server_addr));
|
|
server_addr.sin_family = AF_INET;
|
|
server_addr.sin_port = htons(SERV_PORT);
|
|
inet_pton(AF_INET, argv[1], &server_addr.sin_addr);
|
|
|
|
socklen_t server_len = sizeof(server_addr);
|
|
int connect_rt = connect(socket_fd, (struct sockaddr *) &server_addr, server_len);
|
|
if (connect_rt < 0) {
|
|
error(1, errno, "connect failed ");
|
|
}
|
|
|
|
char recv_line[MAXLINE + 1];
|
|
int n;
|
|
|
|
fd_set readmask;
|
|
fd_set allreads;
|
|
|
|
struct timeval tv;
|
|
int heartbeats = 0;
|
|
|
|
tv.tv_sec = KEEP_ALIVE_TIME;
|
|
tv.tv_usec = 0;
|
|
|
|
messageObject messageObject;
|
|
|
|
FD_ZERO(&allreads);
|
|
FD_SET(socket_fd, &allreads);
|
|
for (;;) {
|
|
readmask = allreads;
|
|
int rc = select(socket_fd + 1, &readmask, NULL, NULL, &tv);
|
|
if (rc < 0) {
|
|
error(1, errno, "select failed");
|
|
}
|
|
if (rc == 0) {
|
|
if (++heartbeats > KEEP_ALIVE_PROBETIMES) {
|
|
error(1, 0, "connection dead\n");
|
|
}
|
|
printf("sending heartbeat #%d\n", heartbeats);
|
|
messageObject.type = htonl(MSG_PING);
|
|
rc = send(socket_fd, (char *) &messageObject, sizeof(messageObject), 0);
|
|
if (rc < 0) {
|
|
error(1, errno, "send failure");
|
|
}
|
|
tv.tv_sec = KEEP_ALIVE_INTERVAL;
|
|
continue;
|
|
}
|
|
if (FD_ISSET(socket_fd, &readmask)) {
|
|
n = read(socket_fd, recv_line, MAXLINE);
|
|
if (n < 0) {
|
|
error(1, errno, "read error");
|
|
} else if (n == 0) {
|
|
error(1, 0, "server terminated \n");
|
|
}
|
|
printf("received heartbeat, make heartbeats to 0 \n");
|
|
heartbeats = 0;
|
|
tv.tv_sec = KEEP_ALIVE_TIME;
|
|
}
|
|
}
|
|
}
|
这个程序主要分成三大部分:
第一部分为套接字的创建和连接建立:
- 15-16 行,创建了 TCP 套接字;
- 18-22 行,创建了 IPv4 目标地址,其实就是服务器端地址,注意这里使用的是传入参数作为服务器地址;
- 24-28 行,向服务器端发起连接。
第二部分为 select 定时器准备:
- 39-40 行,设置了超时时间为 KEEP_ALIVE_TIME,这相当于保活时间;
- 44-45 行,初始化 select 函数的套接字。
最重要的为第三部分,这一部分需要处理心跳报文:
- 48 行调用 select 函数,感知 I/O 事件。这里的 I/O 事件,除了套接字上的读操作之外,还有在 39-40 行设置的超时事件。当 KEEP_ALIVE_TIME 这段时间到达之后,select 函数会返回 0,于是进入 53-63 行的处理;
- 在 53-63 行,客户端已经在 KEEP_ALIVE_TIME 这段时间内没有收到任何对当前连接的反馈,于是发起 PING 消息,尝试问服务器端:”喂,你还活着吗?“这里我们通过传送一个类型为 MSG_PING 的消息对象来完成 PING 操作,之后我们会看到服务器端程序如何响应这个 PING 操作;
- 第 65-74 行是客户端在接收到服务器端程序之后的处理。为了简单,这里就没有再进行报文格式的转换和分析。在实际的工作中,这里其实是需要对报文进行解析后处理的,只有是 PONG 类型的回应,我们才认为是 PING 探活的结果。这里认为既然收到服务器端的报文,那么连接就是正常的,所以会对探活计数器和探活时间都置零,等待下一次探活时间的来临。
服务器端程序设计
服务器端的程序接受一个参数,这个参数设置的比较大,可以模拟连接没有响应的情况。服务器端程序在接收到客户端发送来的各种消息后,进行处理,其中如果发现是 PING 类型的消息,在休眠一段时间后回复一个 PONG 消息,告诉客户端:”嗯,我还活着。“当然,如果这个休眠时间很长的话,那么客户端就无法快速知道服务器端是否存活,这是我们模拟连接无响应的一个手段而已,实际情况下,应该是系统崩溃,或者网络异常。
|
#include "lib/common.h"
|
|
#include "message_objecte.h"
|
|
|
|
static int count;
|
|
|
|
int main(int argc, char **argv) {
|
|
if (argc != 2) {
|
|
error(1, 0, "usage: tcpsever <sleepingtime>");
|
|
}
|
|
|
|
int sleepingTime = atoi(argv[1]);
|
|
|
|
int listenfd;
|
|
listenfd = socket(AF_INET, SOCK_STREAM, 0);
|
|
|
|
struct sockaddr_in server_addr;
|
|
bzero(&server_addr, sizeof(server_addr));
|
|
server_addr.sin_family = AF_INET;
|
|
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
|
|
server_addr.sin_port = htons(SERV_PORT);
|
|
|
|
int rt1 = bind(listenfd, (struct sockaddr *) &server_addr, sizeof(server_addr));
|
|
if (rt1 < 0) {
|
|
error(1, errno, "bind failed ");
|
|
}
|
|
|
|
int rt2 = listen(listenfd, LISTENQ);
|
|
if (rt2 < 0) {
|
|
error(1, errno, "listen failed ");
|
|
}
|
|
|
|
int connfd;
|
|
struct sockaddr_in client_addr;
|
|
socklen_t client_len = sizeof(client_addr);
|
|
|
|
if ((connfd = accept(listenfd, (struct sockaddr *) &client_addr, &client_len)) < 0) {
|
|
error(1, errno, "bind failed ");
|
|
}
|
|
|
|
messageObject message;
|
|
count = 0;
|
|
|
|
for (;;) {
|
|
int n = read(connfd, (char *) &message, sizeof(messageObject));
|
|
if (n < 0) {
|
|
error(1, errno, "error read");
|
|
} else if (n == 0) {
|
|
error(1, 0, "client closed \n");
|
|
}
|
|
|
|
printf("received %d bytes\n", n);
|
|
count++;
|
|
|
|
switch (ntohl(message.type)) {
|
|
case MSG_TYPE1 :
|
|
printf("process MSG_TYPE1 \n");
|
|
break;
|
|
|
|
case MSG_TYPE2 :
|
|
printf("process MSG_TYPE2 \n");
|
|
break;
|
|
|
|
case MSG_PING: {
|
|
messageObject pong_message;
|
|
pong_message.type = MSG_PONG;
|
|
sleep(sleepingTime);
|
|
ssize_t rc = send(connfd, (char *) &pong_message, sizeof(pong_message), 0);
|
|
if (rc < 0)
|
|
error(1, errno, "send failure");
|
|
break;
|
|
}
|
|
|
|
default :
|
|
error(1, 0, "unknown message type (%d)\n", ntohl(message.type));
|
|
}
|
|
|
|
}
|
|
|
|
}
|
服务器端程序主要分为两个部分。
第一部分为监听过程的建立,包括 7-38 行; 第 13-14 行先创建一个本地 TCP 监听套接字;16-20 行绑定该套接字到本地端口和 ANY 地址上;第 27-38 行分别调用 listen 和 accept 完成被动套接字转换和监听。
第二部分为 43 行到 77 行,从建立的连接套接字上读取数据,解析报文,根据消息类型进行不同的处理。
- 55-57 行为处理 MSG_TYPE1 的消息;
- 59-61 行为处理 MSG_TYPE2 的消息;
- 重点是 64-72 行处理 MSG_PING 类型的消息。通过休眠来模拟响应是否及时,然后调用 send 函数发送一个 PONG 报文,向客户端表示”还活着“的意思;
- 74 行为异常处理,因为消息格式不认识,所以程序出错退出。
实验
基于上面的程序设计,让我们分别做两个不同的实验:
第一次实验,服务器端休眠时间为 60 秒。
我们看到,客户端在发送了三次心跳检测报文 PING 报文后,判断出连接无效,直接退出了。之所以造成这样的结果,是因为在这段时间内没有接收到来自服务器端的任何 PONG 报文。当然,实际工作的程序,可能需要不一样的处理,比如重新发起连接。
|
$./pingclient 127.0.0.1
|
|
sending heartbeat #1
|
|
sending heartbeat #2
|
|
sending heartbeat #3
|
|
connection dead
|
|
$./pingserver 60
|
|
received 1028 bytes
|
|
received 1028 bytes
|
第二次实验,我们让服务器端休眠时间为 5 秒。
我们看到,由于这一次服务器端在心跳检测过程中,及时地进行了响应,客户端一直都会认为连接是正常的。
|
$./pingclient 127.0.0.1
|
|
sending heartbeat #1
|
|
sending heartbeat #2
|
|
received heartbeat, make heartbeats to 0
|
|
received heartbeat, make heartbeats to 0
|
|
sending heartbeat #1
|
|
sending heartbeat #2
|
|
received heartbeat, make heartbeats to 0
|
|
received heartbeat, make heartbeats to 0
|
|
$./pingserver 5
|
|
received 1028 bytes
|
|
received 1028 bytes
|
|
received 1028 bytes
|
|
received 1028 bytes
|
总结
通过今天的文章,我们能看到虽然 TCP 没有提供系统的保活能力,让应用程序可以方便地感知连接的存活,但是,我们可以在应用程序里灵活地建立这种机制。一般来说,这种机制的建立依赖于系统定时器,以及恰当的应用层报文协议。
调用数据发送接口以后……
在前面的内容中,我们已经熟悉如何通过套接字发送数据,比如使用 write 或者 send 方法来进行数据流的发送。
我们已经知道,调用这些接口并不意味着数据被真正发送到网络上,其实,这些数据只是从应用程序中被拷贝到了系统内核的套接字缓冲区中,或者说是发送缓冲区中,等待协议栈的处理。至于这些数据是什么时候被发送出去的,对应用程序来说,是无法预知的。对这件事情真正负责的,是运行于操作系统内核的 TCP 协议栈实现模块。
流量控制和生产者 - 消费者模型
我们可以把理想中的 TCP 协议可以想象成一队运输货物的货车,运送的货物就是 TCP 数据包,这些货车将数据包从发送端运送到接收端,就这样不断周而复始。
我们仔细想一下,货物达到接收端之后,是需要卸货处理、登记入库的,接收端限于自己的处理能力和仓库规模,是不可能让这队货车以不可控的速度发货的。接收端肯定会和发送端不断地进行信息同步,比如接收端通知发送端:“后面那 20 车你给我等等,等我这里腾出地方你再继续发货。”
其实这就是发送窗口和接收窗口的本质,我管这个叫做“TCP 的生产者 - 消费者”模型。
发送窗口和接收窗口是 TCP 连接的双方,一个作为生产者,一个作为消费者,为了达到一致协同的生产 - 消费速率、而产生的算法模型实现。
说白了,作为 TCP 发送端,也就是生产者,不能忽略 TCP 的接收端,也就是消费者的实际状况,不管不顾地把数据包都传送过来。如果都传送过来,消费者来不及消费,必然会丢弃;而丢弃反过使得生产者又重传,发送更多的数据包,最后导致网络崩溃。
我想,理解了“TCP 的生产者 - 消费者”模型,再反过来看发送窗口和接收窗口的设计目的和方式,我们就会恍然大悟了。
拥塞控制和数据传输
TCP 的生产者 - 消费者模型,只是在考虑单个连接的数据传递,但是, TCP 数据包是需要经过网卡、交换机、核心路由器等一系列的网络设备的,网络设备本身的能力也是有限的,当多个连接的数据包同时在网络上传送时,势必会发生带宽争抢、数据丢失等,这样,TCP 就必须考虑多个连接共享在有限的带宽上,兼顾效率和公平性的控制,这就是拥塞控制的本质。
举个形象一点的例子,有一个货车行驶在半夜三点的大路上,这样的场景是断然不需要拥塞控制的。
我们可以把网络设备形成的网络信息高速公路和生活中实际的高速公路做个对比。正是因为有多个 TCP 连接,形成了高速公路上的多队运送货车,高速公路上开始变得熙熙攘攘,这个时候,就需要拥塞控制的接入了。
在 TCP 协议中,拥塞控制是通过拥塞窗口来完成的,拥塞窗口的大小会随着网络状况实时调整。
拥塞控制常用的算法有“慢启动”,它通过一定的规则,慢慢地将网络发送数据的速率增加到一个阈值。超过这个阈值之后,慢启动就结束了,另一个叫做“拥塞避免”的算法登场。在这个阶段,TCP 会不断地探测网络状况,并随之不断调整拥塞窗口的大小。
现在你可以发现,在任何一个时刻,TCP 发送缓冲区的数据是否能真正发送出去,至少取决于两个因素,一个是当前的发送窗口大小,另一个是拥塞窗口大小,而 TCP 协议中总是取两者中最小值作为判断依据。比如当前发送的字节为 100,发送窗口的大小是 200,拥塞窗口的大小是 80,那么取 200 和 80 中的最小值,就是 80,当前发送的字节数显然是大于拥塞窗口的,结论就是不能发送出去。
这里千万要分清楚发送窗口和拥塞窗口的区别。
发送窗口反应了作为单 TCP 连接、点对点之间的流量控制模型,它是需要和接收端一起共同协调来调整大小的;而拥塞窗口则是反应了作为多个 TCP 连接共享带宽的拥塞控制模型,它是发送端独立地根据网络状况来动态调整的。
一些有趣的场景
注意我在前面的表述中,提到了在任何一个时刻里,TCP 发送缓冲区的数据是否能真正发送出去,用了“至少两个因素”这个说法,细心的你有没有想过这个问题,除了之前引入的发送窗口、拥塞窗口之外,还有什么其他因素吗?
我们考虑以下几个有趣的场景:
第一个场景,接收端处理得急不可待,比如刚刚读入了 100 个字节,就告诉发送端:“喂,我已经读走 100 个字节了,你继续发”,在这种情况下,你觉得发送端应该怎么做呢?
第二个场景是所谓的“交互式”场景,比如我们使用 telnet 登录到一台服务器上,或者使用 SSH 和远程的服务器交互,这种情况下,我们在屏幕上敲打了一个命令,等待服务器返回结果,这个过程需要不断和服务器端进行数据传输。这里最大的问题是,每次传输的数据可能都非常小,比如敲打的命令“pwd”,仅仅三个字符。这意味着什么?这就好比,每次叫了一辆大货车,只送了一个小水壶。在这种情况下,你又觉得发送端该怎么做才合理呢?
第三个场景是从接收端来说的。我们知道,接收端需要对每个接收到的 TCP 分组进行确认,也就是发送 ACK 报文,但是 ACK 报文本身是不带数据的分段,如果一直这样发送大量的 ACK 报文,就会消耗大量的带宽。之所以会这样,是因为 TCP 报文、IP 报文固有的消息头是不可或缺的,比如两端的地址、端口号、时间戳、序列号等信息, 在这种情形下,你觉得合理的做法是什么?
TCP 之所以复杂,就是因为 TCP 需要考虑的因素较多。像以上这几个场景,都是 TCP 需要考虑的情况,一句话概况就是如何有效地利用网络带宽。
第一个场景也被叫做糊涂窗口综合症,这个场景需要在接收端进行优化。也就是说,接收端不能在接收缓冲区空出一个很小的部分之后,就急吼吼地向发送端发送窗口更新通知,而是需要在自己的缓冲区大到一个合理的值之后,再向发送端发送窗口更新通知。这个合理的值,由对应的 RFC 规范定义。
第二个场景需要在发送端进行优化。这个优化的算法叫做 Nagle 算法,Nagle 算法的本质其实就是限制大批量的小数据包同时发送,为此,它提出,在任何一个时刻,未被确认的小数据包不能超过一个。这里的小数据包,指的是长度小于最大报文段长度 MSS 的 TCP 分组。这样,发送端就可以把接下来连续的几个小数据包存储起来,等待接收到前一个小数据包的 ACK 分组之后,再将数据一次性发送出去。
第三个场景,也是需要在接收端进行优化,这个优化的算法叫做延时 ACK。延时 ACK 在收到数据后并不马上回复,而是累计需要发送的 ACK 报文,等到有数据需要发送给对端时,将累计的 ACK捎带一并发送出去。当然,延时 ACK 机制,不能无限地延时下去,否则发送端误认为数据包没有发送成功,引起重传,反而会占用额外的网络带宽。
禁用 Nagle 算法
有没有发现一个很奇怪的组合,即 Nagle 算法和延时 ACK 的组合。
这个组合为什么奇怪呢?我举一个例子你来体会一下。
比如,客户端分两次将一个请求发送出去,由于请求的第一部分的报文未被确认,Nagle 算法开始起作用;同时延时 ACK 在服务器端起作用,假设延时时间为 200ms,服务器等待 200ms 后,对请求的第一部分进行确认;接下来客户端收到了确认后,Nagle 算法解除请求第二部分的阻止,让第二部分得以发送出去,服务器端在收到之后,进行处理应答,同时将第二部分的确认捎带发送出去。

你从这张图中可以看到,Nagle 算法和延时确认组合在一起,增大了处理时延,实际上,两个优化彼此在阻止对方。
从上面的例子可以看到,在有些情况下 Nagle 算法并不适用, 比如对时延敏感的应用。
幸运的是,我们可以通过对套接字的修改来关闭 Nagle 算法。
|
int on = 1;
|
|
setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, (void *)&on, sizeof(on));
|
值得注意的是,除非我们对此有十足的把握,否则不要轻易改变默认的 TCP Nagle 算法。因为在现代操作系统中,针对 Nagle 算法和延时 ACK 的优化已经非常成熟了,有可能在禁用 Nagle 算法之后,性能问题反而更加严重。
将写操作合并
其实前面的例子里,如果我们能将一个请求一次性发送过去,而不是分开两部分独立发送,结果会好很多。所以,在写数据之前,将数据合并到缓冲区,批量发送出去,这是一个比较好的做法。不过,有时候数据会存储在两个不同的缓存中,对此,我们可以使用如下的方法来进行数据的读写操作,从而避免 Nagle 算法引发的副作用。
|
ssize_t writev(int filedes, const struct iovec *iov, int iovcnt)
|
|
ssize_t readv(int filedes, const struct iovec *iov, int iovcnt);
|
这两个函数的第二个参数都是指向某个 iovec 结构数组的一个指针,其中 iovec 结构定义如下:
|
struct iovec {
|
|
void *iov_base; /* starting address of buffer */
|
|
size_t iov_len; /* size of buffer */
|
|
};”
|
下面的程序展示了集中写的方式:
|
int main(int argc, char **argv) {
|
|
if (argc != 2) {
|
|
error(1, 0, "usage: tcpclient <IPaddress>");
|
|
}
|
|
|
|
int socket_fd;
|
|
socket_fd = socket(AF_INET, SOCK_STREAM, 0);
|
|
|
|
struct sockaddr_in server_addr;
|
|
bzero(&server_addr, sizeof(server_addr));
|
|
server_addr.sin_family = AF_INET;
|
|
server_addr.sin_port = htons(SERV_PORT);
|
|
inet_pton(AF_INET, argv[1], &server_addr.sin_addr);
|
|
|
|
socklen_t server_len = sizeof(server_addr);
|
|
int connect_rt = connect(socket_fd, (struct sockaddr *) &server_addr, server_len);
|
|
if (connect_rt < 0) {
|
|
error(1, errno, "connect failed ");
|
|
}
|
|
|
|
char buf[128];
|
|
struct iovec iov[2];
|
|
|
|
char *send_one = "hello,";
|
|
iov[0].iov_base = send_one;
|
|
iov[0].iov_len = strlen(send_one);
|
|
iov[1].iov_base = buf;
|
|
while (fgets(buf, sizeof(buf), stdin) != NULL) {
|
|
iov[1].iov_len = strlen(buf);
|
|
int n = htonl(iov[1].iov_len);
|
|
if (writev(socket_fd, iov, 2) < 0)
|
|
error(1, errno, "writev failure");
|
|
}
|
|
exit(0);
|
|
}
|
这个程序的前半部分创建套接字,建立连接就不再赘述了。关键的是 24-33 行,使用了 iovec 数组,分别写入了两个不同的字符串,一个是“hello,”,另一个通过标准输入读入。
在启动该程序之前,我们需要启动服务器端程序,在客户端依次输入“world”和“network”:
|
world
|
|
network
|
接下来我们可以看到服务器端接收到了 iovec 组成的新的字符串。这里的原理其实就是在调用 writev 操作时,会自动把几个数组的输入合并成一个有序的字节流,然后发送给对端。
|
received 12 bytes: hello,world
|
|
|
|
received 14 bytes: hello,network
|
总结
今天的内容我重点讲述了 TCP 流量控制的生产者 - 消费者模型,你需要记住以下几点:
- 发送窗口用来控制发送和接收端的流量;阻塞窗口用来控制多条连接公平使用的有限带宽。
- 小数据包加剧了网络带宽的浪费,为了解决这个问题,引入了如 Nagle 算法、延时 ACK 等机制。
- 在程序设计层面,不要多次频繁地发送小报文,如果有,可以使用 writev 批量发送。
在前面的基础篇中,我们已经接触到了 UDP 数据报协议相关的知识,在我们的脑海里,已经深深印上了“UDP 等于无连接协议”的特性。那么看到这一讲的题目,你是不是觉得有点困惑?没关系,和我一起进入”已连接“的 UDP 的世界,回头再看这个标题,相信你就会恍然大悟。
从一个例子开始
我们先从一个客户端例子开始,在这个例子中,客户端在 UDP 套接字上调用 connect 函数,之后将标准输入的字符串发送到服务器端,并从服务器端接收处理后的报文。当然,和服务器端发送和接收报文是通过调用函数 sendto 和 recvfrom 来完成的。
|
#include "lib/common.h"
|
|
# define MAXLINE 4096
|
|
|
|
int main(int argc, char **argv) {
|
|
if (argc != 2) {
|
|
error(1, 0, "usage: udpclient1 <IPaddress>");
|
|
}
|
|
|
|
int socket_fd;
|
|
socket_fd = socket(AF_INET, SOCK_DGRAM, 0);
|
|
|
|
struct sockaddr_in server_addr;
|
|
bzero(&server_addr, sizeof(server_addr));
|
|
server_addr.sin_family = AF_INET;
|
|
server_addr.sin_port = htons(SERV_PORT);
|
|
inet_pton(AF_INET, argv[1], &server_addr.sin_addr);
|
|
|
|
socklen_t server_len = sizeof(server_addr);
|
|
|
|
if (connect(socket_fd, (struct sockaddr *) &server_addr, server_len)) {
|
|
error(1, errno, "connect failed");
|
|
}
|
|
|
|
struct sockaddr *reply_addr;
|
|
reply_addr = malloc(server_len);
|
|
|
|
char send_line[MAXLINE], recv_line[MAXLINE + 1];
|
|
socklen_t len;
|
|
int n;
|
|
|
|
while (fgets(send_line, MAXLINE, stdin) != NULL) {
|
|
int i = strlen(send_line);
|
|
if (send_line[i - 1] == '\n') {
|
|
send_line[i - 1] = 0;
|
|
}
|
|
|
|
printf("now sending %s\n", send_line);
|
|
size_t rt = sendto(socket_fd, send_line, strlen(send_line), 0, (struct sockaddr *) &server_addr, server_len);
|
|
if (rt < 0) {
|
|
error(1, errno, "sendto failed");
|
|
}
|
|
printf("send bytes: %zu \n", rt);
|
|
|
|
len = 0;
|
|
recv_line[0] = 0;
|
|
n = recvfrom(socket_fd, recv_line, MAXLINE, 0, reply_addr, &len);
|
|
if (n < 0)
|
|
error(1, errno, "recvfrom failed");
|
|
recv_line[n] = 0;
|
|
fputs(recv_line, stdout);
|
|
fputs("\n", stdout);
|
|
}
|
|
|
|
exit(0);
|
|
}
|
我对这个程序做一个简单的解释:
- 9-10 行创建了一个 UDP 套接字;
- 12-16 行创建了一个 IPv4 地址,绑定到指定端口和 IP;
- 20-22 行调用 connect 将 UDP 套接字和 IPv4 地址进行了“绑定”,这里 connect 函数的名称有点让人误解,其实可能更好的选择是叫做 setpeername;
- 31-55 行是程序的主体,读取标准输入字符串后,调用 sendto 发送给对端;之后调用 recvfrom 等待对端的响应,并把对端响应信息打印到标准输出。
在没有开启服务端的情况下,我们运行一下这个程序:
|
$ ./udpconnectclient 127.0.0.1
|
|
g1
|
|
now sending g1
|
|
send bytes: 2
|
|
recvfrom failed: Connection refused (111)
|
看到这里你会不会觉得很奇怪?不是说好 UDP 是“无连接”的协议吗?不是说好 UDP 客户端只会阻塞在 recvfrom 这样的调用上吗?怎么这里冒出一个“Connection refused”的错误呢?
别着急,下面就跟着我的思路慢慢去解开这个谜团。
UDP connect 的作用
从前面的例子中,你会发现,我们可以对 UDP 套接字调用 connect 函数,但是和 TCP connect 调用引起 TCP 三次握手,建立 TCP 有效连接不同,UDP connect 函数的调用,并不会引起和服务器目标端的网络交互,也就是说,并不会触发所谓的”握手“报文发送和应答。
那么对 UDP 套接字进行 connect 操作到底有什么意义呢?
其实上面的例子已经给出了答案,这主要是为了让应用程序能够接收”异步错误“的信息。
如果我们回想一下第 6 篇不调用 connect 操作的客户端程序,在服务器端不开启的情况下,客户端程序是不会报错的,程序只会阻塞在 recvfrom 上,等待返回(或者超时)。
在这里,我们通过对 UDP 套接字进行 connect 操作,将 UDP 套接字建立了”上下文“,该套接字和服务器端的地址和端口产生了联系,正是这种绑定关系给了操作系统内核必要的信息,能够将操作系统内核收到的信息和对应的套接字进行关联。
我们可以展开讨论一下。
事实上,当我们调用 sendto 或者 send 操作函数时,应用程序报文被发送,我们的应用程序返回,操作系统内核接管了该报文,之后操作系统开始尝试往对应的地址和端口发送,因为对应的地址和端口不可达,一个 ICMP 报文会返回给操作系统内核,该 ICMP 报文含有目的地址和端口等信息。
如果我们不进行 connect 操作,建立(UDP 套接字——目的地址 + 端口)之间的映射关系,操作系统内核就没有办法把 ICMP 不可达的信息和 UDP 套接字进行关联,也就没有办法将 ICMP 信息通知给应用程序。
如果我们进行了 connect 操作,帮助操作系统内核从容建立了(UDP 套接字——目的地址 + 端口)之间的映射关系,当收到一个 ICMP 不可达报文时,操作系统内核可以从映射表中找出是哪个 UDP 套接字拥有该目的地址和端口,别忘了套接字在操作系统内部是全局唯一的,当我们在该套接字上再次调用 recvfrom 或 recv 方法时,就可以收到操作系统内核返回的”Connection Refused“的信息。
收发函数
在对 UDP 进行 connect 之后,关于收发函数的使用,很多书籍是这样推荐的:
- 使用 send 或 write 函数来发送,如果使用 sendto 需要把相关的 to 地址信息置零;
- 使用 recv 或 read 函数来接收,如果使用 recvfrom 需要把对应的 from 地址信息置零。
其实不同的 UNIX 实现对此表现出来的行为不尽相同。
在我的 Linux 4.4.0 环境中,使用 sendto 和 recvfrom,系统会自动忽略 to 和 from 信息。在我的 macOS 10.13 中,确实需要遵守这样的规定,使用 sendto 或 recvfrom 会得到一些奇怪的结果,切回 send 和 recv 后正常。
考虑到兼容性,我们也推荐这些常规做法。所以在接下来的程序中,我会使用这样的做法来实现。
服务器端 connect 的例子
一般来说,服务器端不会主动发起 connect 操作,因为一旦如此,服务器端就只能响应一个客户端了。不过,有时候也不排除这样的情形,一旦一个客户端和服务器端发送 UDP 报文之后,该服务器端就要服务于这个唯一的客户端。
一个类似的服务器端程序如下:
|
#include "lib/common.h"
|
|
|
|
static int count;
|
|
|
|
static void recvfrom_int(int signo) {
|
|
printf("\nreceived %d datagrams\n", count);
|
|
exit(0);
|
|
}
|
|
|
|
int main(int argc, char **argv) {
|
|
int socket_fd;
|
|
socket_fd = socket(AF_INET, SOCK_DGRAM, 0);
|
|
|
|
struct sockaddr_in server_addr;
|
|
bzero(&server_addr, sizeof(server_addr));
|
|
server_addr.sin_family = AF_INET;
|
|
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
|
|
server_addr.sin_port = htons(SERV_PORT);
|
|
|
|
bind(socket_fd, (struct sockaddr *) &server_addr, sizeof(server_addr));
|
|
|
|
socklen_t client_len;
|
|
char message[MAXLINE];
|
|
message[0] = 0;
|
|
count = 0;
|
|
|
|
signal(SIGINT, recvfrom_int);
|
|
|
|
struct sockaddr_in client_addr;
|
|
client_len = sizeof(client_addr);
|
|
|
|
int n = recvfrom(socket_fd, message, MAXLINE, 0, (struct sockaddr *) &client_addr, &client_len);
|
|
if (n < 0) {
|
|
error(1, errno, "recvfrom failed");
|
|
}
|
|
message[n] = 0;
|
|
printf("received %d bytes: %s\n", n, message);
|
|
|
|
if (connect(socket_fd, (struct sockaddr *) &client_addr, client_len)) {
|
|
error(1, errno, "connect failed");
|
|
}
|
|
|
|
while (strncmp(message, "goodbye", 7) != 0) {
|
|
char send_line[MAXLINE];
|
|
sprintf(send_line, "Hi, %s", message);
|
|
|
|
size_t rt = send(socket_fd, send_line, strlen(send_line), 0);
|
|
if (rt < 0) {
|
|
error(1, errno, "send failed ");
|
|
}
|
|
printf("send bytes: %zu \n", rt);
|
|
|
|
size_t rc = recv(socket_fd, message, MAXLINE, 0);
|
|
if (rc < 0) {
|
|
error(1, errno, "recv failed");
|
|
}
|
|
|
|
count++;
|
|
}
|
|
|
|
exit(0);
|
|
}
|
我对这个程序做下解释:
- 11-12 行创建 UDP 套接字;
- 14-18 行创建 IPv4 地址,绑定到 ANY 和对应端口;
- 20 行绑定 UDP 套接字和 IPv4 地址;
- 27 行为该程序注册一个信号处理函数,以响应 Ctrl+C 信号量操作;
- 32-37 行调用 recvfrom 等待客户端报文到达,并将客户端信息保持到 client_addr 中;
- 39-41 行调用 connect 操作,将 UDP 套接字和客户端 client_addr 进行绑定;
- 43-59 行是程序的主体,对接收的信息进行重新处理,加上”Hi“前缀后发送给客户端,并持续不断地从客户端接收报文,该过程一直持续,直到客户端发送”goodbye“报文为止。
注意这里所有收发函数都使用了 send 和 recv。
接下来我们实现一个 connect 的客户端程序:
|
#include "lib/common.h"
|
|
# define MAXLINE 4096
|
|
|
|
int main(int argc, char **argv) {
|
|
if (argc != 2) {
|
|
error(1, 0, "usage: udpclient3 <IPaddress>");
|
|
}
|
|
|
|
int socket_fd;
|
|
socket_fd = socket(AF_INET, SOCK_DGRAM, 0);
|
|
|
|
struct sockaddr_in server_addr;
|
|
bzero(&server_addr, sizeof(server_addr));
|
|
server_addr.sin_family = AF_INET;
|
|
server_addr.sin_port = htons(SERV_PORT);
|
|
inet_pton(AF_INET, argv[1], &server_addr.sin_addr);
|
|
|
|
socklen_t server_len = sizeof(server_addr);
|
|
|
|
if (connect(socket_fd, (struct sockaddr *) &server_addr, server_len)) {
|
|
error(1, errno, "connect failed");
|
|
}
|
|
|
|
char send_line[MAXLINE], recv_line[MAXLINE + 1];
|
|
int n;
|
|
|
|
while (fgets(send_line, MAXLINE, stdin) != NULL) {
|
|
int i = strlen(send_line);
|
|
if (send_line[i - 1] == '\n') {
|
|
send_line[i - 1] = 0;
|
|
}
|
|
|
|
printf("now sending %s\n", send_line);
|
|
size_t rt = send(socket_fd, send_line, strlen(send_line), 0);
|
|
if (rt < 0) {
|
|
error(1, errno, "send failed ");
|
|
}
|
|
printf("send bytes: %zu \n", rt);
|
|
|
|
recv_line[0] = 0;
|
|
n = recv(socket_fd, recv_line, MAXLINE, 0);
|
|
if (n < 0)
|
|
error(1, errno, "recv failed");
|
|
recv_line[n] = 0;
|
|
fputs(recv_line, stdout);
|
|
fputs("\n", stdout);
|
|
}
|
|
|
|
exit(0);
|
|
}
|
我对这个客户端程序做一下解读:
- 9-10 行创建了一个 UDP 套接字;
- 12-16 行创建了一个 IPv4 地址,绑定到指定端口和 IP;
- 20-22 行调用 connect 将 UDP 套接字和 IPv4 地址进行了“绑定”;
- 27-46 行是程序的主体,读取标准输入字符串后,调用 send 发送给对端;之后调用 recv 等待对端的响应,并把对端响应信息打印到标准输出。
注意这里所有收发函数也都使用了 send 和 recv。
接下来,我们先启动服务器端程序,然后依次开启两个客户端,分别是客户端 1、客户端 2,并且让客户端 1 先发送 UDP 报文。
服务器端:
|
$ ./udpconnectserver
|
|
received 2 bytes: g1
|
|
send bytes: 6
|
客户端 1:
|
./udpconnectclient2 127.0.0.1
|
|
g1
|
|
now sending g1
|
|
send bytes: 2
|
|
Hi, g1
|
客户端 2:
|
./udpconnectclient2 127.0.0.1
|
|
g2
|
|
now sending g2
|
|
send bytes: 2
|
|
recv failed: Connection refused (111)
|
我们看到,客户端 1 先发送报文,服务端随之通过 connect 和客户端 1 进行了“绑定”,这样,客户端 2 从操作系统内核得到了 ICMP 的错误,该错误在 recv 函数中返回,显示了“Connection refused”的错误信息。
性能考虑
一般来说,客户端通过 connect 绑定服务端的地址和端口,对 UDP 而言,可以有一定程度的性能提升。
这是为什么呢?
因为如果不使用 connect 方式,每次发送报文都会需要这样的过程:
连接套接字→发送报文→断开套接字→连接套接字→发送报文→断开套接字 →………
而如果使用 connect 方式,就会变成下面这样:
连接套接字→发送报文→发送报文→……→最后断开套接字
我们知道,连接套接字是需要一定开销的,比如需要查找路由表信息。所以,UDP 客户端程序通过 connect 可以获得一定的性能提升。
总结
在今天的内容里,我对 UDP 套接字调用 connect 方法进行了深入的分析。之所以对 UDP 使用 connect,绑定本地地址和端口,是为了让我们的程序可以快速获取异步错误信息的通知,同时也可以获得一定性能上的提升。
我们已经知道,网络编程中,服务器程序需要绑定本地地址和一个端口,然后就监听在这个地址和端口上,等待客户端连接的到来。在实战中,你可能会经常碰到一个问题,当服务器端程序重启之后,总是碰到“Address in use”的报错信息,服务器程序不能很快地重启。那么这个问题是如何产生的?我们又该如何避免呢?
今天我们就来讲一讲这个“地址已经被使用”的问题。
从例子开始
为了引入讨论,我们从之前讲过的一个 TCP 服务器端程序开始说起:
|
static int count;
|
|
|
|
static void sig_int(int signo) {
|
|
printf("\nreceived %d datagrams\n", count);
|
|
exit(0);
|
|
}
|
|
|
|
int main(int argc, char **argv) {
|
|
int listenfd;
|
|
listenfd = socket(AF_INET, SOCK_STREAM, 0);
|
|
|
|
struct sockaddr_in server_addr;
|
|
bzero(&server_addr, sizeof(server_addr));
|
|
server_addr.sin_family = AF_INET;
|
|
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
|
|
server_addr.sin_port = htons(SERV_PORT);
|
|
|
|
int rt1 = bind(listenfd, (struct sockaddr *) &server_addr, sizeof(server_addr));
|
|
if (rt1 < 0) {
|
|
error(1, errno, "bind failed ");
|
|
}
|
|
|
|
int rt2 = listen(listenfd, LISTENQ);
|
|
if (rt2 < 0) {
|
|
error(1, errno, "listen failed ");
|
|
}
|
|
|
|
signal(SIGPIPE, SIG_IGN);
|
|
|
|
int connfd;
|
|
struct sockaddr_in client_addr;
|
|
socklen_t client_len = sizeof(client_addr);
|
|
|
|
if ((connfd = accept(listenfd, (struct sockaddr *) &client_addr, &client_len)) < 0) {
|
|
error(1, errno, "bind failed ");
|
|
}
|
|
|
|
char message[MAXLINE];
|
|
count = 0;
|
|
|
|
for (;;) {
|
|
int n = read(connfd, message, MAXLINE);
|
|
if (n < 0) {
|
|
error(1, errno, "error read");
|
|
} else if (n == 0) {
|
|
error(1, 0, "client closed \n");
|
|
}
|
|
message[n] = 0;
|
|
printf("received %d bytes: %s\n", n, message);
|
|
count++;
|
|
}
|
|
}
|
这个服务器端程序绑定到一个本地端口,使用的是通配地址 ANY,当连接建立之后,从该连接中读取输入的字符流。
启动服务器,之后我们使用 Telnet 登录这个服务器,并在屏幕上输入一些字符,例如:network,good。
和我们期望的一样,服务器端打印出 Telnet 客户端的输入。在 Telnet 端关闭连接之后,服务器端接收到 EOF,也顺利地关闭了连接。服务器端也可以很快重启,等待新的连接到来。
|
$./addressused
|
|
received 9 bytes: network
|
|
received 6 bytes: good
|
|
client closed
|
|
$./addressused
|
接下来,我们改变一下连接的关闭顺序。和前面的过程一样,先启动服务器,再使用 Telnet 作为客户端登录到服务器,在屏幕上输入一些字符。注意接下来的不同,我不会在 Telnet 端关闭连接,而是直接使用 Ctrl+C 的方式在服务器端关闭连接。
|
$telneet 127.0.0.1 9527
|
|
network
|
|
bad
|
|
Connection closed by foreign host.
|
我们看到,连接已经被关闭,Telnet 客户端也感知连接关闭并退出了。接下来,我们尝试重启服务器端程序。你会发现,这个时候服务端程序重启失败,报错信息为:bind failed: Address already in use。
|
$./addressused
|
|
received 9 bytes: network
|
|
received 6 bytes: good
|
|
client closed
|
|
$./addressused
|
|
bind faied: Address already in use(98)
|
复习 TIME_WAIT
那么,这个错误到底是怎么发生的呢?
还记得第 10 篇文章里提到的 TIME_WAIT 么?当连接的一方主动关闭连接,在接收到对端的 FIN 报文之后,主动关闭连接的一方会在 TIME_WAIT 这个状态里停留一段时间,这个时间大约为 2MSL。如果你对此有点淡忘,没有关系,我在下面放了一张图,希望会唤起你的记忆。

如果我们此时使用 netstat 去查看服务器程序所在主机的 TIME_WAIT 的状态连接,你会发现有一个服务器程序生成的 TCP 连接,当前正处于 TIME_WAIT 状态。这里 9527 是本地监听端口,36650 是 telnet 客户端端口。当然了,Telnet 客户端端口每次也会不尽相同。

通过服务器端发起的关闭连接操作,引起了一个已有的 TCP 连接处于 TME_WAIT 状态,正是这个 TIME_WAIT 的连接,使得服务器重启时,继续绑定在 127.0.0.1 地址和 9527 端口上的操作,返回了Address already in use的错误。
重用套接字选项
我们知道,一个 TCP 连接是通过四元组(源地址、源端口、目的地址、目的端口)来唯一确定的,如果每次 Telnet 客户端使用的本地端口都不同,就不会和已有的四元组冲突,也就不会有 TIME_WAIT 的新旧连接化身冲突的问题。
事实上,即使在很小的概率下,客户端 Telnet 使用了相同的端口,从而造成了新连接和旧连接的四元组相同,在现代 Linux 操作系统下,也不会有什么大的问题,原因是现代 Linux 操作系统对此进行了一些优化。
第一种优化是新连接 SYN 告知的初始序列号,一定比 TIME_WAIT 老连接的末序列号大,这样通过序列号就可以区别出新老连接。
第二种优化是开启了 tcp_timestamps,使得新连接的时间戳比老连接的时间戳大,这样通过时间戳也可以区别出新老连接。
在这样的优化之下,一个 TIME_WAIT 的 TCP 连接可以忽略掉旧连接,重新被新的连接所使用。
这就是重用套接字选项,通过给套接字配置可重用属性,告诉操作系统内核,这样的 TCP 连接完全可以复用 TIME_WAIT 状态的连接。代码片段已经放在文章中了:
|
int on = 1;
|
|
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
|
SO_REUSEADDR 套接字选项,允许启动绑定在一个端口,即使之前存在一个和该端口一样的连接。前面的例子已经表明,在默认情况下,服务器端历经创建 socket、bind 和 listen 重启时,如果试图绑定到一个现有连接上的端口,bind 操作会失败,但是如果我们在创建 socket 和 bind 之间,使用上面的代码片段设置 SO_REUSEADDR 套接字选项,情况就会不同。
下面我们对原来的服务器端代码进行升级,升级的部分主要在 11-12 行,在 bind 监听套接字之前,调用 setsockopt 方法,设置重用套接字选项:
|
nt main(int argc, char **argv) {
|
|
int listenfd;
|
|
listenfd = socket(AF_INET, SOCK_STREAM, 0);
|
|
|
|
struct sockaddr_in server_addr;
|
|
bzero(&server_addr, sizeof(server_addr));
|
|
server_addr.sin_family = AF_INET;
|
|
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
|
|
server_addr.sin_port = htons(SERV_PORT);
|
|
|
|
int on = 1;
|
|
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
|
|
|
|
int rt1 = bind(listenfd, (struct sockaddr *) &server_addr, sizeof(server_addr));
|
|
if (rt1 < 0) {
|
|
error(1, errno, "bind failed ");
|
|
}
|
|
|
|
int rt2 = listen(listenfd, LISTENQ);
|
|
if (rt2 < 0) {
|
|
error(1, errno, "listen failed ");
|
|
}
|
|
|
|
signal(SIGPIPE, SIG_IGN);
|
|
|
|
int connfd;
|
|
struct sockaddr_in client_addr;
|
|
socklen_t client_len = sizeof(client_addr);
|
|
|
|
if ((connfd = accept(listenfd, (struct sockaddr *) &client_addr, &client_len)) < 0) {
|
|
error(1, errno, "bind failed ");
|
|
}
|
|
|
|
char message[MAXLINE];
|
|
count = 0;
|
|
|
|
for (;;) {
|
|
int n = read(connfd, message, MAXLINE);
|
|
if (n < 0) {
|
|
error(1, errno, "error read");
|
|
} else if (n == 0) {
|
|
error(1, 0, "client closed \n");
|
|
}
|
|
message[n] = 0;
|
|
printf("received %d bytes: %s\n", n, message);
|
|
count++;
|
|
}
|
|
}
|
重新编译过后,重复上面那个例子,先启动服务器,再使用 Telnet 作为客户端登录到服务器,在屏幕上输入一些字符,使用 Ctrl+C 的方式在服务器端关闭连接。马上尝试重启服务器,这个时候我们发现,服务器正常启动,没有出现Address already in use的错误。这说明我们的修改已经起作用。
|
$./addressused2
|
|
received 9 bytes: network
|
|
received 6 bytes: good
|
|
client closed
|
|
$./addressused2
|
SO_REUSEADDR 套接字选项还有一个作用,那就是本机服务器如果有多个地址,可以在不同地址上使用相同的端口提供服务。
比如,一台服务器有 192.168.1.101 和 10.10.2.102 连个地址,我们可以在这台机器上启动三个不同的 HTTP 服务,第一个以本地通配地址 ANY 和端口 80 启动;第二个以 192.168.101 和端口 80 启动;第三个以 10.10.2.102 和端口 80 启动。
这样目的地址为 192.168.101,目的端口为 80 的连接请求会被发往第二个服务;目的地址为 10.10.2.102,目的端口为 80 的连接请求会被发往第三个服务;目的端口为 80 的所有其他连接请求被发往第一个服务。
我们必须给这三个服务设置 SO_REUSEADDR 套接字选项,否则第二个和第三个服务调用 bind 绑定到 80 端口时会出错。
最佳实践
这里的最佳实践可以总结成一句话: 服务器端程序,都应该设置 SO_REUSEADDR 套接字选项,以便服务端程序可以在极短时间内复用同一个端口启动。
有些人可能觉得这不是安全的。其实,单独重用一个套接字不会有任何问题。我在前面已经讲过,TCP 连接是通过四元组唯一区分的,只要客户端不使用相同的源端口,连接服务器是没有问题的,即使使用了相同的端口,根据序列号或者时间戳,也是可以区分出新旧连接的。
而且,TCP 的机制绝对不允许在相同的地址和端口上绑定不同的服务器,即使我们设置 SO_REUSEADDR 套接字选项,也不可能在 ANY 通配符地址下和端口 9527 上重复启动两个服务器实例。如果我们启动第二个服务器实例,不出所料会得到Address already in use的报错,即使当前还没有任何一条有效 TCP 连接产生。
比如下面就是第二次运行服务器端程序的报错信息:
|
$./addressused2
|
|
bind faied: Address already in use(98)
|
你可能还记得第 10 讲中,我们提到过一个叫做 tcp_tw_reuse 的内核配置选项,这里又提到了 SO_REUSEADDR 套接字选择,你会不会觉得两个有点混淆呢?
其实,这两个东西一点关系也没有。
- tcp_tw_reuse 是内核选项,主要用在连接的发起方。TIME_WAIT 状态的连接创建时间超过 1 秒后,新的连接才可以被复用,注意,这里是连接的发起方;
- SO_REUSEADDR 是用户态的选项,SO_REUSEADDR 选项用来告诉操作系统内核,如果端口已被占用,但是 TCP 连接状态位于 TIME_WAIT ,可以重用端口。如果端口忙,而 TCP 处于其他状态,重用端口时依旧得到“Address already in use”的错误信息。注意,这里一般都是连接的服务方。
总结
今天我们分析了“Address already in use”产生的原因和解决方法。你只要记住一句话,在所有 TCP 服务器程序中,调用 bind 之前请设置 SO_REUSEADDR 套接字选项。这不会产生危害,相反,它会帮助我们在很快时间内重启服务端程序,而这一点恰恰是很多场景所需要的。
什么是 I/O 多路复用
在第 11 讲中,我们设计了这样一个应用程序,该程序从标准输入接收数据输入,然后通过套接字发送出去,同时,该程序也通过套接字接收对方发送的数据流。
我们可以使用 fgets 方法等待标准输入,但是一旦这样做,就没有办法在套接字有数据的时候读出数据;我们也可以使用 read 方法等待套接字有数据返回,但是这样做,也没有办法在标准输入有数据的情况下,读入数据并发送给对方。
I/O 多路复用的设计初衷就是解决这样的场景。我们可以把标准输入、套接字等都看做 I/O 的一路,多路复用的意思,就是在任何一路 I/O 有“事件”发生的情况下,通知应用程序去处理相应的 I/O 事件,这样我们的程序就变成了“多面手”,在同一时刻仿佛可以处理多个 I/O 事件。
像刚才的例子,使用 I/O 复用以后,如果标准输入有数据,立即从标准输入读入数据,通过套接字发送出去;如果套接字有数据可以读,立即可以读出数据。
select 函数就是这样一种常见的 I/O 多路复用技术,我们将在后面继续讲解其他的多路复用技术。使用 select 函数,通知内核挂起进程,当一个或多个 I/O 事件发生后,控制权返还给应用程序,由应用程序进行 I/O 事件的处理。
这些 I/O 事件的类型非常多,比如:
- 标准输入文件描述符准备好可以读。
- 监听套接字准备好,新的连接已经建立成功。
- 已连接套接字准备好可以写。
- 如果一个 I/O 事件等待超过了 10 秒,发生了超时事件。
select 函数的使用方法
select 函数的使用方法有点复杂,我们先看一下它的声明:
|
int select(int maxfd, fd_set *readset, fd_set *writeset, fd_set *exceptset, const struct timeval *timeout);
|
|
|
|
返回:若有就绪描述符则为其数目,若超时则为 0,若出错则为 -1
|
在这个函数中,n 表示的是待测试的描述符基数,它的值是待测试的最大描述符加 1。比如现在的 select 待测试的描述符集合是{0,1,4},那么 maxfd 就是 5,为啥是 5,而不是 4 呢? 我会在下面进行解释。
紧接着的是三个描述符集合,分别是读描述符集合 readset、写描述符集合 writeset 和异常描述符集合 exceptset,这三个分别通知内核,在哪些描述符上检测数据可以读,可以写和有异常发生。
那么如何设置这些描述符集合呢?以下的宏可以帮助到我们。
|
void FD_ZERO(fd_set *fdset);
|
|
void FD_SET(int fd, fd_set *fdset);
|
|
void FD_CLR(int fd, fd_set *fdset);
|
|
int FD_ISSET(int fd, fd_set *fdset);
|
如果你刚刚入门,理解这些宏可能有些困难。没有关系,我们可以这样想象,下面一个向量代表了一个描述符集合,其中,这个向量的每个元素都是二机制数中的 0 或者 1。
|
a[maxfd-1], ..., a[1], a[0]
|
我们按照这样的思路来理解这些宏:
- FD_ZERO 用来将这个向量的所有元素都设置成 0;
- FD_SET 用来把对应套接字 fd 的元素,a[fd] 设置成 1;
- FD_CLR 用来把对应套接字 fd 的元素,a[fd] 设置成 0;
- FD_ISSET 对这个向量进行检测,判断出对应套接字的元素 a[fd] 是 0 还是 1。
其中 0 代表不需要处理,1 代表需要处理。
怎么样,是不是感觉豁然开朗了?
实际上,很多系统是用一个整型数组来表示一个描述字集合的,一个 32 位的整型数可以表示 32 个描述字,例如第一个整型数表示 0-31 描述字,第二个整型数可以表示 32-63 描述字,以此类推。
这个时候再来理解为什么描述字集合{0,1,4},对应的 maxfd 是 5,而不是 4,就比较方便了。
因为这个向量对应的是下面这样的:
|
a[4],a[3],a[2],a[1],a[0]
|
待测试的描述符个数显然是 5, 而不是 4。
三个描述符集合中的每一个都可以设置成空,这样就表示不需要内核进行相关的检测。
最后一个参数是 timeval 结构体时间:
|
struct timeval {
|
|
long tv_sec; /* seconds */
|
|
long tv_usec; /* microseconds */
|
|
};
|
这个参数设置成不同的值,会有不同的可能:
第一个可能是设置成空 (NULL),表示如果没有 I/O 事件发生,则 select 一直等待下去。
第二个可能是设置一个非零的值,这个表示等待固定的一段时间后从 select 阻塞调用中返回,这在第 12 讲超时的例子里曾经使用过。
第三个可能是将 tv_sec 和 tv_usec 都设置成 0,表示根本不等待,检测完毕立即返回。这种情况使用得比较少。
程序例子
下面是一个具体的程序例子,我们通过这个例子来理解 select 函数。
|
int main(int argc, char **argv) {
|
|
if (argc != 2) {
|
|
error(1, 0, "usage: select01 <IPaddress>");
|
|
}
|
|
int socket_fd = tcp_client(argv[1], SERV_PORT);
|
|
|
|
char recv_line[MAXLINE], send_line[MAXLINE];
|
|
int n;
|
|
|
|
fd_set readmask;
|
|
fd_set allreads;
|
|
FD_ZERO(&allreads);
|
|
FD_SET(0, &allreads);
|
|
FD_SET(socket_fd, &allreads);
|
|
|
|
for (;;) {
|
|
readmask = allreads;
|
|
int rc = select(socket_fd + 1, &readmask, NULL, NULL, NULL);
|
|
|
|
if (rc <= 0) {
|
|
error(1, errno, "select failed");
|
|
}
|
|
|
|
if (FD_ISSET(socket_fd, &readmask)) {
|
|
n = read(socket_fd, recv_line, MAXLINE);
|
|
if (n < 0) {
|
|
error(1, errno, "read error");
|
|
} else if (n == 0) {
|
|
error(1, 0, "server terminated \n");
|
|
}
|
|
recv_line[n] = 0;
|
|
fputs(recv_line, stdout);
|
|
fputs("\n", stdout);
|
|
}
|
|
|
|
if (FD_ISSET(STDIN_FILENO, &readmask)) {
|
|
if (fgets(send_line, MAXLINE, stdin) != NULL) {
|
|
int i = strlen(send_line);
|
|
if (send_line[i - 1] == '\n') {
|
|
send_line[i - 1] = 0;
|
|
}
|
|
|
|
printf("now sending %s\n", send_line);
|
|
size_t rt = write(socket_fd, send_line, strlen(send_line));
|
|
if (rt < 0) {
|
|
error(1, errno, "write failed ");
|
|
}
|
|
printf("send bytes: %zu \n", rt);
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
程序的 12 行通过 FD_ZERO 初始化了一个描述符集合,这个描述符读集合是空的:

接下来程序的第 13 和 14 行,分别使用 FD_SET 将描述符 0,即标准输入,以及连接套接字描述符 3 设置为待检测:

接下来的 16-51 行是循环检测,这里我们没有阻塞在 fgets 或 read 调用,而是通过 select 来检测套接字描述字有数据可读,或者标准输入有数据可读。比如,当用户通过标准输入使得标准输入描述符可读时,返回的 readmask 的值为:

这个时候 select 调用返回,可以使用 FD_ISSET 来判断哪个描述符准备好可读了。如上图所示,这个时候是标准输入可读,37-51 行程序读入后发送给对端。
如果是连接描述字准备好可读了,第 24 行判断为真,使用 read 将套接字数据读出。
我们需要注意的是,这个程序的 17-18 行非常重要,初学者很容易在这里掉坑里去。
第 17 行是每次测试完之后,重新设置待测试的描述符集合。你可以看到上面的例子,在 select 测试之前的数据是{0,3},select 测试之后就变成了{0}。
这是因为 select 调用每次完成测试之后,内核都会修改描述符集合,通过修改完的描述符集合来和应用程序交互,应用程序使用 FD_ISSET 来对每个描述符进行判断,从而知道什么样的事件发生。
第 18 行则是使用 socket_fd+1 来表示待测试的描述符基数。切记需要 +1。
套接字描述符就绪条件
当我们说 select 测试返回,某个套接字准备好可读,表示什么样的事件发生呢?
第一种情况是套接字接收缓冲区有数据可以读,如果我们使用 read 函数去执行读操作,肯定不会被阻塞,而是会直接读到这部分数据。
第二种情况是对方发送了 FIN,使用 read 函数执行读操作,不会被阻塞,直接返回 0。
第三种情况是针对一个监听套接字而言的,有已经完成的连接建立,此时使用 accept 函数去执行不会阻塞,直接返回已经完成的连接。
第四种情况是套接字有错误待处理,使用 read 函数去执行读操作,不阻塞,且返回 -1。
总结成一句话就是,内核通知我们套接字有数据可以读了,使用 read 函数不会阻塞。
不知道你是不是和我一样,刚开始理解某个套接字可写的时候,会有一个错觉,总是从应用程序角度出发去理解套接字可写,我开始是这样想的,当应用程序完成相应的计算,有数据准备发送给对端了,可以往套接字写,对应的就是套接字可写。
其实这个理解是非常不正确的,select 检测套接字可写,完全是基于套接字本身的特性来说的,具体来说有以下几种情况。
第一种是套接字发送缓冲区足够大,如果我们使用非阻塞套接字进行 write 操作,将不会被阻塞,直接返回。
第二种是连接的写半边已经关闭,如果继续进行写操作将会产生 SIGPIPE 信号。
第三种是套接字上有错误待处理,使用 write 函数去执行读操作,不阻塞,且返回 -1。
总结成一句话就是,内核通知我们套接字可以往里写了,使用 write 函数就不会阻塞。
总结
今天我讲了 select 函数的使用。select 函数提供了最基本的 I/O 多路复用方法,在使用 select 时,我们需要建立两个重要的认识:
- 描述符基数是当前最大描述符 +1;
- 每次 select 调用完成之后,记得要重置待测试集合。
poll 函数介绍
poll 是除了 select 之外,另一种普遍使用的 I/O 多路复用技术,和 select 相比,它和内核交互的数据结构有所变化,另外,也突破了文件描述符的个数限制。
下面是 poll 函数的原型:
|
int poll(struct pollfd *fds, unsigned long nfds, int timeout);
|
|
|
|
返回值:若有就绪描述符则为其数目,若超时则为 0,若出错则为 -1
|
这个函数里面输入了三个参数,第一个参数是一个 pollfd 的数组。其中 pollfd 的结构如下:
|
struct pollfd {
|
|
int fd; /* file descriptor */
|
|
short events; /* events to look for */
|
|
short revents; /* events returned */
|
|
};
|
这个结构体由三个部分组成,首先是描述符 fd,然后是描述符上待检测的事件类型 events,注意这里的 events 可以表示多个不同的事件,具体的实现可以通过使用二进制掩码位操作来完成,例如,POLLIN 和 POLLOUT 可以表示读和写事件。
|
#define POLLIN 0x0001 /* any readable data available */
|
|
#define POLLPRI 0x0002 /* OOB/Urgent readable data */
|
|
#define POLLOUT 0x0004 /* file descriptor is writeable */
|
和 select 非常不同的地方在于,poll 每次检测之后的结果不会修改原来的传入值,而是将结果保留在 revents 字段中,这样就不需要每次检测完都得重置待检测的描述字和感兴趣的事件。我们可以把 revents 理解成“returned events”。
events 类型的事件可以分为两大类。
第一类是可读事件,有以下几种:
|
#define POLLIN 0x0001 /* any readable data available */
|
|
#define POLLPRI 0x0002 /* OOB/Urgent readable data */
|
|
#define POLLRDNORM 0x0040 /* non-OOB/URG data available */
|
|
#define POLLRDBAND 0x0080 /* OOB/Urgent readable data */
|
一般我们在程序里面有 POLLIN 即可。套接字可读事件和 select 的 readset 基本一致,是系统内核通知应用程序有数据可以读,通过 read 函数执行操作不会被阻塞。
第二类是可写事件,有以下几种:
|
#define POLLOUT 0x0004 /* file descriptor is writeable */
|
|
#define POLLWRNORM POLLOUT /* no write type differentiation */
|
|
#define POLLWRBAND 0x0100 /* OOB/Urgent data can be written */
|
一般我们在程序里面统一使用 POLLOUT。套接字可写事件和 select 的 writeset 基本一致,是系统内核通知套接字缓冲区已准备好,通过 write 函数执行写操作不会被阻塞。
以上两大类的事件都可以在“returned events”得到复用。还有另一大类事件,没有办法通过 poll 向系统内核递交检测请求,只能通过“returned events”来加以检测,这类事件是各种错误事件。
|
#define POLLERR 0x0008 /* 一些错误发送 */
|
|
#define POLLHUP 0x0010 /* 描述符挂起 */
|
|
#define POLLNVAL 0x0020 /* 请求的事件无效 */
|
我们再回过头看一下 poll 函数的原型。参数 nfds 描述的是数组 fds 的大小,简单说,就是向 poll 申请的事件检测的个数。
最后一个参数 timeout,描述了 poll 的行为。
如果是一个 <0 的数,表示在有事件发生之前永远等待;如果是 0,表示不阻塞进程,立即返回;如果是一个 >0 的数,表示 poll 调用方等待指定的毫秒数后返回。
关于返回值,当有错误发生时,poll 函数的返回值为 -1;如果在指定的时间到达之前没有任何事件发生,则返回 0,否则就返回检测到的事件个数,也就是“returned events”中非 0 的描述符个数。
poll 函数有一点非常好,如果我们不想对某个 pollfd 结构进行事件检测,可以把它对应的 pollfd 结构的 fd 成员设置成一个负值。这样,poll 函数将忽略这样的 events 事件,检测完成以后,所对应的“returned events”的成员值也将设置为 0。
和 select 函数对比一下,我们发现 poll 函数和 select 不一样的地方就是,在 select 里面,文件描述符的个数已经随着 fd_set 的实现而固定,没有办法对此进行配置;而在 poll 函数里,我们可以控制 pollfd 结构的数组大小,这意味着我们可以突破原来 select 函数最大描述符的限制,在这种情况下,应用程序调用者需要分配 pollfd 数组并通知 poll 函数该数组的大小。
基于 poll 的服务器程序
下面我们将开发一个基于 poll 的服务器程序。这个程序可以同时处理多个客户端连接,并且一旦有客户端数据接收后,同步地回显回去。这已经是一个颇具高并发处理的服务器原型了,再加上后面讲到的非阻塞 I/O 和多线程等技术,基本上就是可使用的准生产级别了。
所以,让我们打起精神,一起来看这个程序。
|
#define INIT_SIZE 128
|
|
|
|
int main(int argc, char **argv) {
|
|
int listen_fd, connected_fd;
|
|
int ready_number;
|
|
ssize_t n;
|
|
char buf[MAXLINE];
|
|
struct sockaddr_in client_addr;
|
|
|
|
listen_fd = tcp_server_listen(SERV_PORT);
|
|
|
|
// 初始化 pollfd 数组,这个数组的第一个元素是 listen_fd,其余的用来记录将要连接的 connect_fd
|
|
struct pollfd event_set[INIT_SIZE];
|
|
event_set[0].fd = listen_fd;
|
|
event_set[0].events = POLLRDNORM;
|
|
|
|
// 用 -1 表示这个数组位置还没有被占用
|
|
int i;
|
|
for (i = 1; i < INIT_SIZE; i++) {
|
|
event_set[i].fd = -1;
|
|
}
|
|
|
|
for (;;) {
|
|
if ((ready_number = poll(event_set, INIT_SIZE, -1)) < 0) {
|
|
error(1, errno, "poll failed ");
|
|
}
|
|
|
|
if (event_set[0].revents & POLLRDNORM) {
|
|
socklen_t client_len = sizeof(client_addr);
|
|
connected_fd = accept(listen_fd, (struct sockaddr *) &client_addr, &client_len);
|
|
|
|
// 找到一个可以记录该连接套接字的位置
|
|
for (i = 1; i < INIT_SIZE; i++) {
|
|
if (event_set[i].fd < 0) {
|
|
event_set[i].fd = connected_fd;
|
|
event_set[i].events = POLLRDNORM;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (i == INIT_SIZE) {
|
|
error(1, errno, "can not hold so many clients");
|
|
}
|
|
|
|
if (--ready_number <= 0)
|
|
continue;
|
|
}
|
|
|
|
for (i = 1; i < INIT_SIZE; i++) {
|
|
int socket_fd;
|
|
if ((socket_fd = event_set[i].fd) < 0)
|
|
continue;
|
|
if (event_set[i].revents & (POLLRDNORM | POLLERR)) {
|
|
if ((n = read(socket_fd, buf, MAXLINE)) > 0) {
|
|
if (write(socket_fd, buf, n) < 0) {
|
|
error(1, errno, "write error");
|
|
}
|
|
} else if (n == 0 || errno == ECONNRESET) {
|
|
close(socket_fd);
|
|
event_set[i].fd = -1;
|
|
} else {
|
|
error(1, errno, "read error");
|
|
}
|
|
|
|
if (--ready_number <= 0)
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
当然,一开始需要创建一个监听套接字,并绑定在本地的地址和端口上,这在第 10 行调用 tcp_server_listen 函数来完成。
在第 13 行,我初始化了一个 pollfd 数组,并命名为 event_set,之所以叫这个名字,是引用 pollfd 数组确实代表了检测的事件集合。这里数组的大小固定为 INIT_SIZE,这在实际的生产环境肯定是需要改进的。
我在前面讲过,监听套接字上如果有连接建立完成,也是可以通过 I/O 事件复用来检测到的。在第 14-15 行,将监听套接字 listen_fd 和对应的 POLLRDNORM 事件加入到 event_set 里,表示我们期望系统内核检测监听套接字上的连接建立完成事件。
在前面介绍 poll 函数时,我们提到过,如果对应 pollfd 里的文件描述字 fd 为负数,poll 函数将会忽略这个 pollfd,所以我们在第 18-21 行将 event_set 数组里其他没有用到的 fd 统统设置为 -1。这里 -1 也表示了当前 pollfd 没有被使用的意思。
下面我们的程序进入一个无限循环,在这个循环体内,第 24 行调用 poll 函数来进行事件检测。poll 函数传入的参数为 event_set 数组,数组大小 INIT_SIZE 和 -1。这里之所以传入 INIT_SIZE,是因为 poll 函数已经能保证可以自动忽略 fd 为 -1 的 pollfd,否则我们每次都需要计算一下 event_size 里真正需要被检测的元素大小;timeout 设置为 -1,表示在 I/O 事件发生之前 poll 调用一直阻塞。
如果系统内核检测到监听套接字上的连接建立事件,就进入到第 28 行的判断分支。我们看到,使用了如 event_set[0].revent 来和对应的事件类型进行位与操作,这个技巧大家一定要记住,这是因为 event 都是通过二进制位来进行记录的,位与操作是和对应的二进制位进行操作,一个文件描述字是可以对应到多个事件类型的。
在这个分支里,调用 accept 函数获取了连接描述字。接下来,33-38 行做了一件事,就是把连接描述字 connect_fd 也加入到 event_set 里,而且说明了我们感兴趣的事件类型为 POLLRDNORM,也就是套集字上有数据可以读。在这里,我们从数组里查找一个没有没占用的位置,也就是 fd 为 -1 的位置,然后把 fd 设置为新的连接套接字 connect_fd。
如果在数组里找不到这样一个位置,说明我们的 event_set 已经被很多连接充满了,没有办法接收更多的连接了,这就是第 41-42 行所做的事情。
第 45-46 行是一个加速优化能力,因为 poll 返回的一个整数,说明了这次 I/O 事件描述符的个数,如果处理完监听套接字之后,就已经完成了这次 I/O 复用所要处理的事情,那么我们就可以跳过后面的处理,再次进入 poll 调用。
接下来的循环处理是查看 event_set 里面其他的事件,也就是已连接套接字的可读事件。这是通过遍历 event_set 数组来完成的。
如果数组里的 pollfd 的 fd 为 -1,说明这个 pollfd 没有递交有效的检测,直接跳过;来到第 53 行,通过检测 revents 的事件类型是 POLLRDNORM 或者 POLLERR,我们可以进行读操作。在第 54 行,读取数据正常之后,再通过 write 操作回显给客户端;在第 58 行,如果读到 EOF 或者是连接重置,则关闭这个连接,并且把 event_set 对应的 pollfd 重置;第 61 行读取数据失败。
和前面的优化加速处理一样,第 65-66 行是判断如果事件已经被完全处理完之后,直接跳过对 event_set 的循环处理,再次来到 poll 调用。
实验
我们启动这个服务器程序,然后通过 telnet 连接到这个服务器程序。为了检验这个服务器程序的 I/O 复用能力,我们可以多开几个 telnet 客户端,并且在屏幕上输入各种字符串。
客户端 1:
|
$telnet 127.0.0.1 43211
|
|
Trying 127.0.0.1...
|
|
Connected to 127.0.0.1.
|
|
Escape character is '^]'.
|
|
a
|
|
a
|
|
aaaaaaaaaaa
|
|
aaaaaaaaaaa
|
|
afafasfa
|
|
afafasfa
|
|
fbaa
|
|
fbaa
|
|
^]
|
|
|
|
|
|
telnet> quit
|
|
Connection closed.
|
客户端 2:
|
telnet 127.0.0.1 43211
|
|
Trying 127.0.0.1...
|
|
Connected to 127.0.0.1.
|
|
Escape character is '^]'.
|
|
b
|
|
b
|
|
bbbbbbb
|
|
bbbbbbb
|
|
bbbbbbb
|
|
bbbbbbb
|
|
^]
|
|
|
|
|
|
telnet> quit
|
|
Connection closed.
|
可以看到,这两个客户端互不影响,每个客户端输入的字符很快会被回显到客户端屏幕上。一个客户端断开连接,也不会影响到其他客户端。
总结
poll 是另一种在各种 UNIX 系统上被广泛支持的 I/O 多路复用技术,虽然名声没有 select 那么响,能力一点不比 select 差,而且因为可以突破 select 文件描述符的个数限制,在高并发的场景下尤其占优势。这一讲我们编写了一个基于 poll 的服务器程序,希望你从中学会 poll 的用法。
在性能篇的前两讲中,我分别介绍了 select 和 poll 两种不同的 I/O 多路复用技术。在接下来的这一讲中,我将带大家进入非阻塞 I/O 模式的世界。事实上,非阻塞 I/O 配合 I/O 多路复用,是高性能网络编程中的常见技术。
阻塞 VS 非阻塞
当应用程序调用阻塞 I/O 完成某个操作时,应用程序会被挂起,等待内核完成操作,感觉上应用程序像是被“阻塞”了一样。实际上,内核所做的事情是将 CPU 时间切换给其他有需要的进程,网络应用程序在这种情况下就会得不到 CPU 时间做该做的事情。
非阻塞 I/O 则不然,当应用程序调用非阻塞 I/O 完成某个操作时,内核立即返回,不会把 CPU 时间切换给其他进程,应用程序在返回后,可以得到足够的 CPU 时间继续完成其他事情。
如果拿去书店买书举例子,阻塞 I/O 对应什么场景呢? 你去了书店,告诉老板(内核)你想要某本书,然后你就一直在那里等着,直到书店老板翻箱倒柜找到你想要的书,有可能还要帮你联系全城其它分店。注意,这个过程中你一直滞留在书店等待老板的回复,好像在书店老板这里"阻塞"住了。
那么非阻塞 I/O 呢?你去了书店,问老板有没你心仪的那本书,老板查了下电脑,告诉你没有,你就悻悻离开了。一周以后,你又来这个书店,再问这个老板,老板一查,有了,于是你买了这本书。注意,这个过程中,你没有被阻塞,而是在不断轮询。
但轮询的效率太低了,于是你向老板提议:“老板,到货给我打电话吧,我再来付钱取书。”这就是前面讲到的 I/O 多路复用。
再进一步,你连去书店取书也想省了,得了,让老板代劳吧,你留下地址,付了书费,让老板到货时寄给你,你直接在家里拿到就可以看了。这就是我们将会在第 30 讲中讲到的异步 I/O。
这几个 I/O 模型,再加上进程、线程模型,构成了整个网络编程的知识核心。
按照使用场景,非阻塞 I/O 可以被用到读操作、写操作、接收连接操作和发起连接操作上。接下来,我们对它们一一解读。
非阻塞 I/O
读操作
如果套接字对应的接收缓冲区没有数据可读,在非阻塞情况下 read 调用会立即返回,一般返回 EWOULDBLOCK 或 EAGAIN 出错信息。在这种情况下,出错信息是需要小心处理,比如后面再次调用 read 操作,而不是直接作为错误直接返回。这就好像去书店买书没买到离开一样,需要不断进行又一次轮询处理。
写操作
不知道你有没有注意到,在阻塞 I/O 情况下,write 函数返回的字节数,和输入的参数总是一样的。如果返回值总是和输入的数据大小一样,write 等写入函数还需要定义返回值吗?我不知道你是不是和我一样,刚接触到这一部分知识的时候有这种困惑。
这里就要引出我们所说的非阻塞 I/O。在非阻塞 I/O 的情况下,如果套接字的发送缓冲区已达到了极限,不能容纳更多的字节,那么操作系统内核会尽最大可能从应用程序拷贝数据到发送缓冲区中,并立即从 write 等函数调用中返回。可想而知,在拷贝动作发生的瞬间,有可能一个字符也没拷贝,有可能所有请求字符都被拷贝完成,那么这个时候就需要返回一个数值,告诉应用程序到底有多少数据被成功拷贝到了发送缓冲区中,应用程序需要再次调用 write 函数,以输出未完成拷贝的字节。
write 等函数是可以同时作用到阻塞 I/O 和非阻塞 I/O 上的,为了复用一个函数,处理非阻塞和阻塞 I/O 多种情况,设计出了写入返回值,并用这个返回值表示实际写入的数据大小。
也就是说,非阻塞 I/O 和阻塞 I/O 处理的方式是不一样的。
非阻塞 I/O 需要这样:拷贝→返回→再拷贝→再返回。
而阻塞 I/O 需要这样:拷贝→直到所有数据拷贝至发送缓冲区完成→返回。
不过在实战中,你可以不用区别阻塞和非阻塞 I/O,使用循环的方式来写入数据就好了。只不过在阻塞 I/O 的情况下,循环只执行一次就结束了。
我在前面的章节中已经介绍了类似的方案,你可以在文稿里看到 writen 函数的实现。
|
/* 向文件描述符 fd 写入 n 字节数 */
|
|
ssize_t writen(int fd, const void * data, size_t n)
|
|
{
|
|
size_t nleft;
|
|
ssize_t nwritten;
|
|
const char *ptr;
|
|
|
|
ptr = data;
|
|
nleft = n;
|
|
// 如果还有数据没被拷贝完成,就一直循环
|
|
while (nleft > 0) {
|
|
if ( (nwritten = write(fd, ptr, nleft)) <= 0) {
|
|
/* 这里 EINTR 是非阻塞 non-blocking 情况下,通知我们再次调用 write() */
|
|
if (nwritten < 0 && errno == EINTR)
|
|
nwritten = 0;
|
|
else
|
|
return -1; /* 出错退出 */
|
|
}
|
|
|
|
/* 指针增大,剩下字节数变小 */
|
|
nleft -= nwritten;
|
|
ptr += nwritten;
|
|
}
|
|
return n;
|
|
}
|
下面我通过一张表来总结一下 read 和 write 在阻塞模式和非阻塞模式下的不同行为特性:

关于 read 和 write 还有几个结论,你需要把握住:
- read 总是在接收缓冲区有数据时就立即返回,不是等到应用程序给定的数据充满才返回。当接收缓冲区为空时,阻塞模式会等待,非阻塞模式立即返回 -1,并有 EWOULDBLOCK 或 EAGAIN 错误。
- 和 read 不同,阻塞模式下,write 只有在发送缓冲区足以容纳应用程序的输出字节时才返回;而非阻塞模式下,则是能写入多少就写入多少,并返回实际写入的字节数。
- 阻塞模式下的 write 有个特例, 就是对方主动关闭了套接字,这个时候 write 调用会立即返回,并通过返回值告诉应用程序实际写入的字节数,如果再次对这样的套接字进行 write 操作,就会返回失败。失败是通过返回值 -1 来通知到应用程序的。
accept
当 accept 和 I/O 多路复用 select、poll 等一起配合使用时,如果在监听套接字上触发事件,说明有连接建立完成,此时调用 accept 肯定可以返回已连接套接字。这样看来,似乎把监听套接字设置为非阻塞,没有任何好处。
为了说明这个问题,我们构建一个客户端程序,其中最关键的是,一旦连接建立,设置 SO_LINGER 套接字选项,把 l_onoff 标志设置为 1,把 l_linger 时间设置为 0。这样,连接被关闭时,TCP 套接字上将会发送一个 RST。
|
struct linger ling;
|
|
ling.l_onoff = 1;
|
|
ling.l_linger = 0;
|
|
setsockopt(socket_fd, SOL_SOCKET, SO_LINGER, &ling, sizeof(ling));
|
|
close(socket_fd);
|
服务器端使用 select I/O 多路复用,不过,监听套接字仍然是 blocking 的。如果监听套接字上有事件发生,休眠 5 秒,以便模拟高并发场景下的情形。
|
if (FD_ISSET(listen_fd, &readset)) {
|
|
printf("listening socket readable\n");
|
|
sleep(5);
|
|
struct sockaddr_storage ss;
|
|
socklen_t slen = sizeof(ss);
|
|
int fd = accept(listen_fd, (struct sockaddr *) &ss, &slen);
|
这里的休眠时间非常关键,这样,在监听套接字上有可读事件发生时,并没有马上调用 accept。由于客户端发生了 RST 分节,该连接被接收端内核从自己的已完成队列中删除了,此时再调用 accept,由于没有已完成连接(假设没有其他已完成连接),accept 一直阻塞,更为严重的是,该线程再也没有机会对其他 I/O 事件进行分发,相当于该服务器无法对新连接和其他 I/O 进行服务。
如果我们将监听套接字设为非阻塞,上述的情形就不会再发生。只不过对于 accept 的返回值,需要正确地处理各种看似异常的错误,例如忽略 EWOULDBLOCK、EAGAIN 等。
这个例子给我们的启发是,一定要将监听套接字设置为非阻塞的,尽管这里休眠时间 5 秒有点夸张,但是在极端情况下处理不当的服务器程序是有可能碰到文稿中例子所阐述的情况,为了让服务器程序在极端情况下工作正常,这点工作还是非常值得的。
connect
在非阻塞 TCP 套接字上调用 connect 函数,会立即返回一个 EINPROGRESS 错误。TCP 三次握手会正常进行,应用程序可以继续做其他初始化的事情。当该连接建立成功或者失败时,通过 I/O 多路复用 select、poll 等可以进行连接的状态检测。
非阻塞 I/O + select 多路复用
文稿中给出了一个非阻塞 I/O 搭配 select 多路复用的例子。
|
#define MAX_LINE 1024
|
|
#define FD_INIT_SIZE 128
|
|
|
|
char rot13_char(char c) {
|
|
if ((c >= 'a' && c <= 'm') || (c >= 'A' && c <= 'M'))
|
|
return c + 13;
|
|
else if ((c >= 'n' && c <= 'z') || (c >= 'N' && c <= 'Z'))
|
|
return c - 13;
|
|
else
|
|
return c;
|
|
}
|
|
|
|
// 数据缓冲区
|
|
struct Buffer {
|
|
int connect_fd; // 连接字
|
|
char buffer[MAX_LINE]; // 实际缓冲
|
|
size_t writeIndex; // 缓冲写入位置
|
|
size_t readIndex; // 缓冲读取位置
|
|
int readable; // 是否可以读
|
|
};
|
|
|
|
struct Buffer *alloc_Buffer() {
|
|
struct Buffer *buffer = malloc(sizeof(struct Buffer));
|
|
if (!buffer)
|
|
return NULL;
|
|
buffer->connect_fd = 0;
|
|
buffer->writeIndex = buffer->readIndex = buffer->readable = 0;
|
|
return buffer;
|
|
}
|
|
|
|
void free_Buffer(struct Buffer *buffer) {
|
|
free(buffer);
|
|
}
|
|
|
|
int onSocketRead(int fd, struct Buffer *buffer) {
|
|
char buf[1024];
|
|
int i;
|
|
ssize_t result;
|
|
while (1) {
|
|
result = recv(fd, buf, sizeof(buf), 0);
|
|
if (result <= 0)
|
|
break;
|
|
|
|
for (i = 0; i < result; ++i) {
|
|
if (buffer->writeIndex < sizeof(buffer->buffer))
|
|
buffer->buffer[buffer->writeIndex++] = rot13_char(buf[i]);
|
|
if (buf[i] == '\n') {
|
|
buffer->readable = 1; // 缓冲区可以读
|
|
}
|
|
}
|
|
}
|
|
|
|
if (result == 0) {
|
|
return 1;
|
|
} else if (result < 0) {
|
|
if (errno == EAGAIN)
|
|
return 0;
|
|
return -1;
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
int onSocketWrite(int fd, struct Buffer *buffer) {
|
|
while (buffer->readIndex < buffer->writeIndex) {
|
|
ssize_t result = send(fd, buffer->buffer + buffer->readIndex, buffer->writeIndex - buffer->readIndex, 0);
|
|
if (result < 0) {
|
|
if (errno == EAGAIN)
|
|
return 0;
|
|
return -1;
|
|
}
|
|
|
|
buffer->readIndex += result;
|
|
}
|
|
|
|
if (buffer->readIndex == buffer->writeIndex)
|
|
buffer->readIndex = buffer->writeIndex = 0;
|
|
|
|
buffer->readable = 0;
|
|
|
|
return 0;
|
|
}
|
|
|
|
int main(int argc, char **argv) {
|
|
int listen_fd;
|
|
int i, maxfd;
|
|
|
|
struct Buffer *buffer[FD_INIT_SIZE];
|
|
for (i = 0; i < FD_INIT_SIZE; ++i) {
|
|
buffer[i] = alloc_Buffer();
|
|
}
|
|
|
|
listen_fd = tcp_nonblocking_server_listen(SERV_PORT);
|
|
|
|
fd_set readset, writeset, exset;
|
|
FD_ZERO(&readset);
|
|
FD_ZERO(&writeset);
|
|
FD_ZERO(&exset);
|
|
|
|
while (1) {
|
|
maxfd = listen_fd;
|
|
|
|
FD_ZERO(&readset);
|
|
FD_ZERO(&writeset);
|
|
FD_ZERO(&exset);
|
|
|
|
// listener 加入 readset
|
|
FD_SET(listen_fd, &readset);
|
|
|
|
for (i = 0; i < FD_INIT_SIZE; ++i) {
|
|
if (buffer[i]->connect_fd > 0) {
|
|
if (buffer[i]->connect_fd > maxfd)
|
|
maxfd = buffer[i]->connect_fd;
|
|
FD_SET(buffer[i]->connect_fd, &readset);
|
|
if (buffer[i]->readable) {
|
|
FD_SET(buffer[i]->connect_fd, &writeset);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (select(maxfd + 1, &readset, &writeset, &exset, NULL) < 0) {
|
|
error(1, errno, "select error");
|
|
}
|
|
|
|
if (FD_ISSET(listen_fd, &readset)) {
|
|
printf("listening socket readable\n");
|
|
sleep(5);
|
|
struct sockaddr_storage ss;
|
|
socklen_t slen = sizeof(ss);
|
|
int fd = accept(listen_fd, (struct sockaddr *) &ss, &slen);
|
|
if (fd < 0) {
|
|
error(1, errno, "accept failed");
|
|
} else if (fd > FD_INIT_SIZE) {
|
|
error(1, 0, "too many connections");
|
|
close(fd);
|
|
} else {
|
|
make_nonblocking(fd);
|
|
if (buffer[fd]->connect_fd == 0) {
|
|
buffer[fd]->connect_fd = fd;
|
|
} else {
|
|
error(1, 0, "too many connections");
|
|
}
|
|
}
|
|
}
|
|
|
|
for (i = 0; i < maxfd + 1; ++i) {
|
|
int r = 0;
|
|
if (i == listen_fd)
|
|
continue;
|
|
|
|
if (FD_ISSET(i, &readset)) {
|
|
r = onSocketRead(i, buffer[i]);
|
|
}
|
|
if (r == 0 && FD_ISSET(i, &writeset)) {
|
|
r = onSocketWrite(i, buffer[i]);
|
|
}
|
|
if (r) {
|
|
buffer[i]->connect_fd = 0;
|
|
close(i);
|
|
}
|
|
}
|
|
}
|
|
}
|
第 93 行,调用 fcntl 将监听套接字设置为非阻塞。
|
fcntl(fd, F_SETFL, O_NONBLOCK);
|
第 121 行调用 select 进行 I/O 事件分发处理。
131-142 行在处理新的连接套接字,注意这里也把连接套接字设置为非阻塞的。
151-156 行在处理连接套接字上的 I/O 读写事件,这里我们抽象了一个 Buffer 对象,Buffer 对象使用了 readIndex 和 writeIndex 分别表示当前缓冲的读写位置。
实验
启动该服务器:
|
$./nonblockingserver
|
使用多个 telnet 客户端连接该服务器,可以验证交互正常。
|
$telnet 127.0.0.1 43211
|
|
Trying 127.0.0.1...
|
|
Connected to localhost.
|
|
Escape character is '^]'.
|
|
fasfasfasf
|
|
snfsnfsnfs
|
总结
非阻塞 I/O 可以使用在 read、write、accept、connect 等多种不同的场景,在非阻塞 I/O 下,使用轮询的方式引起 CPU 占用率高,所以一般将非阻塞 I/O 和 I/O 多路复用技术 select、poll 等搭配使用,在非阻塞 I/O 事件发生时,再调用对应事件的处理函数。这种方式,极大地提高了程序的健壮性和稳定性,是 Linux 下高性能网络编程的首选。
性能篇的前三讲,非阻塞 I/O 加上 I/O 多路复用,已经渐渐帮助我们在高性能网络编程这个领域搭建了初步的基石。但是,离最终的目标还差那么一点,如果说 I/O 多路复用帮我们打开了高性能网络编程的窗口,那么今天的主题——epoll,将为我们增添足够的动力。
我在文稿中放置了一张图,这张图来自 The Linux Programming Interface(No Starch Press)。这张图直观地为我们展示了 select、poll、epoll 几种不同的 I/O 复用技术在面对不同文件描述符大小时的表现差异。

从图中可以明显地看到,epoll 的性能是最好的,即使在多达 10000 个文件描述的情况下,其性能的下降和有 10 个文件描述符的情况相比,差别也不是很大。而随着文件描述符的增大,常规的 select 和 poll 方法性能逐渐变得很差。
那么,epoll 究竟使用了什么样的“魔法”,取得了如此令人惊讶的效果呢?接下来,我们就来一起分析一下。
epoll 的用法
在分析对比 epoll、poll 和 select 几种技术之前,我们先看一下怎么使用 epoll 来完成一个服务器程序,具体的原理我将在 29 讲中进行讲解。
epoll 可以说是和 poll 非常相似的一种 I/O 多路复用技术,有些朋友将 epoll 归为异步 I/O,我觉得这是不正确的。本质上 epoll 还是一种 I/O 多路复用技术, epoll 通过监控注册的多个描述字,来进行 I/O 事件的分发处理。不同于 poll 的是,epoll 不仅提供了默认的 level-triggered(条件触发)机制,还提供了性能更为强劲的 edge-triggered(边缘触发)机制。至于这两种机制的区别,我会在后面详细展开。
使用 epoll 进行网络程序的编写,需要三个步骤,分别是 epoll_create,epoll_ctl 和 epoll_wait。接下来我对这几个 API 详细展开讲一下。
epoll_create
|
int epoll_create(int size);
|
|
int epoll_create1(int flags);
|
|
返回值: 若成功返回一个大于 0 的值,表示 epoll 实例;若返回 -1 表示出错
|
epoll_create() 方法创建了一个 epoll 实例,从 Linux 2.6.8 开始,参数 size 被自动忽略,但是该值仍需要一个大于 0 的整数。这个 epoll 实例被用来调用 epoll_ctl 和 epoll_wait,如果这个 epoll 实例不再需要,比如服务器正常关机,需要调用 close() 方法释放 epoll 实例,这样系统内核可以回收 epoll 实例所分配使用的内核资源。
关于这个参数 size,在一开始的 epoll_create 实现中,是用来告知内核期望监控的文件描述字大小,然后内核使用这部分的信息来初始化内核数据结构,在新的实现中,这个参数不再被需要,因为内核可以动态分配需要的内核数据结构。我们只需要注意,每次将 size 设置成一个大于 0 的整数就可以了。
epoll_create1() 的用法和 epoll_create() 基本一致,如果 epoll_create1() 的输入 size 大小为 0,则和 epoll_create() 一样,内核自动忽略。可以增加如 EPOLL_CLOEXEC 的额外选项,如果你有兴趣的话,可以研究一下这个选项有什么意义。
epoll_ctl
|
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
|
|
返回值: 若成功返回 0;若返回 -1 表示出错
|
在创建完 epoll 实例之后,可以通过调用 epoll_ctl 往这个 epoll 实例增加或删除监控的事件。函数 epll_ctl 有 4 个入口参数。
第一个参数 epfd 是刚刚调用 epoll_create 创建的 epoll 实例描述字,可以简单理解成是 epoll 句柄。
第二个参数表示增加还是删除一个监控事件,它有三个选项可供选择:
- EPOLL_CTL_ADD: 向 epoll 实例注册文件描述符对应的事件;
- EPOLL_CTL_DEL:向 epoll 实例删除文件描述符对应的事件;
- EPOLL_CTL_MOD: 修改文件描述符对应的事件。
第三个参数是注册的事件的文件描述符,比如一个监听套接字。
第四个参数表示的是注册的事件类型,并且可以在这个结构体里设置用户需要的数据,其中最为常见的是使用联合结构里的 fd 字段,表示事件所对应的文件描述符。
|
typedef union epoll_data {
|
|
void *ptr;
|
|
int fd;
|
|
uint32_t u32;
|
|
uint64_t u64;
|
|
} epoll_data_t;
|
|
|
|
struct epoll_event {
|
|
uint32_t events; /* Epoll events */
|
|
epoll_data_t data; /* User data variable */
|
|
};
|
我们在前面介绍 poll 的时候已经接触过基于 mask 的事件类型了,这里 epoll 仍旧使用了同样的机制,我们重点看一下这几种事件类型:
- EPOLLIN:表示对应的文件描述字可以读;
- EPOLLOUT:表示对应的文件描述字可以写;
- EPOLLRDHUP:表示套接字的一端已经关闭,或者半关闭;
- EPOLLHUP:表示对应的文件描述字被挂起;
- EPOLLET:设置为 edge-triggered,默认为 level-triggered。
epoll_wait
|
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
|
|
返回值: 成功返回的是一个大于 0 的数,表示事件的个数;返回 0 表示的是超时时间到;若出错返回 -1.
|
epoll_wait() 函数类似之前的 poll 和 select 函数,调用者进程被挂起,在等待内核 I/O 事件的分发。
这个函数的第一个参数是 epoll 实例描述字,也就是 epoll 句柄。
第二个参数返回给用户空间需要处理的 I/O 事件,这是一个数组,数组的大小由 epoll_wait 的返回值决定,这个数组的每个元素都是一个需要待处理的 I/O 事件,其中 events 表示具体的事件类型,事件类型取值和 epoll_ctl 可设置的值一样,这个 epoll_event 结构体里的 data 值就是在 epoll_ctl 那里设置的 data,也就是用户空间和内核空间调用时需要的数据。
第三个参数是一个大于 0 的整数,表示 epoll_wait 可以返回的最大事件值。
第四个参数是 epoll_wait 阻塞调用的超时值,如果这个值设置为 -1,表示不超时;如果设置为 0 则立即返回,即使没有任何 I/O 事件发生。
epoll 例子
代码解析
下面我们把原先基于 poll 的服务器端程序改造成基于 epoll 的:
|
#include "lib/common.h"
|
|
|
|
#define MAXEVENTS 128
|
|
|
|
char rot13_char(char c) {
|
|
if ((c >= 'a' && c <= 'm') || (c >= 'A' && c <= 'M'))
|
|
return c + 13;
|
|
else if ((c >= 'n' && c <= 'z') || (c >= 'N' && c <= 'Z'))
|
|
return c - 13;
|
|
else
|
|
return c;
|
|
}
|
|
|
|
int main(int argc, char **argv) {
|
|
int listen_fd, socket_fd;
|
|
int n, i;
|
|
int efd;
|
|
struct epoll_event event;
|
|
struct epoll_event *events;
|
|
|
|
listen_fd = tcp_nonblocking_server_listen(SERV_PORT);
|
|
|
|
efd = epoll_create1(0);
|
|
if (efd == -1) {
|
|
error(1, errno, "epoll create failed");
|
|
}
|
|
|
|
event.data.fd = listen_fd;
|
|
event.events = EPOLLIN | EPOLLET;
|
|
if (epoll_ctl(efd, EPOLL_CTL_ADD, listen_fd, &event) == -1) {
|
|
error(1, errno, "epoll_ctl add listen fd failed");
|
|
}
|
|
|
|
/* Buffer where events are returned */
|
|
events = calloc(MAXEVENTS, sizeof(event));
|
|
|
|
while (1) {
|
|
n = epoll_wait(efd, events, MAXEVENTS, -1);
|
|
printf("epoll_wait wakeup\n");
|
|
for (i = 0; i < n; i++) {
|
|
if ((events[i].events & EPOLLERR) ||
|
|
(events[i].events & EPOLLHUP) ||
|
|
(!(events[i].events & EPOLLIN))) {
|
|
fprintf(stderr, "epoll error\n");
|
|
close(events[i].data.fd);
|
|
continue;
|
|
} else if (listen_fd == events[i].data.fd) {
|
|
struct sockaddr_storage ss;
|
|
socklen_t slen = sizeof(ss);
|
|
int fd = accept(listen_fd, (struct sockaddr *) &ss, &slen);
|
|
if (fd < 0) {
|
|
error(1, errno, "accept failed");
|
|
} else {
|
|
make_nonblocking(fd);
|
|
event.data.fd = fd;
|
|
event.events = EPOLLIN | EPOLLET; //edge-triggered
|
|
if (epoll_ctl(efd, EPOLL_CTL_ADD, fd, &event) == -1) {
|
|
error(1, errno, "epoll_ctl add connection fd failed");
|
|
}
|
|
}
|
|
continue;
|
|
} else {
|
|
socket_fd = events[i].data.fd;
|
|
printf("get event on socket fd == %d \n", socket_fd);
|
|
while (1) {
|
|
char buf[512];
|
|
if ((n = read(socket_fd, buf, sizeof(buf))) < 0) {
|
|
if (errno != EAGAIN) {
|
|
error(1, errno, "read error");
|
|
close(socket_fd);
|
|
}
|
|
break;
|
|
} else if (n == 0) {
|
|
close(socket_fd);
|
|
break;
|
|
} else {
|
|
for (i = 0; i < n; ++i) {
|
|
buf[i] = rot13_char(buf[i]);
|
|
}
|
|
if (write(socket_fd, buf, n) < 0) {
|
|
error(1, errno, "write error");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
free(events);
|
|
close(listen_fd);
|
|
}
|
程序的第 23 行调用 epoll_create0 创建了一个 epoll 实例。
28-32 行,调用 epoll_ctl 将监听套接字对应的 I/O 事件进行了注册,这样在有新的连接建立之后,就可以感知到。注意这里使用的是 edge-triggered(边缘触发)。
35 行为返回的 event 数组分配了内存。
主循环调用 epoll_wait 函数分发 I/O 事件,当 epoll_wait 成功返回时,通过遍历返回的 event 数组,就直接可以知道发生的 I/O 事件。
第 41-46 行判断了各种错误情况。
第 47-61 行是监听套接字上有事件发生的情况下,调用 accept 获取已建立连接,并将该连接设置为非阻塞,再调用 epoll_ctl 把已连接套接字对应的可读事件注册到 epoll 实例中。这里我们使用了 event_data 里面的 fd 字段,将连接套接字存储其中。
第 63-84 行,处理了已连接套接字上的可读事件,读取字节流,编码后再回应给客户端。
实验
启动该服务器:
|
$./epoll01
|
|
epoll_wait wakeup
|
|
epoll_wait wakeup
|
|
epoll_wait wakeup
|
|
get event on socket fd == 6
|
|
epoll_wait wakeup
|
|
get event on socket fd == 5
|
|
epoll_wait wakeup
|
|
get event on socket fd == 5
|
|
epoll_wait wakeup
|
|
get event on socket fd == 6
|
|
epoll_wait wakeup
|
|
get event on socket fd == 6
|
|
epoll_wait wakeup
|
|
get event on socket fd == 6
|
|
epoll_wait wakeup
|
|
get event on socket fd == 5
|
再启动几个 telnet 客户端,可以看到有连接建立情况下,epoll_wait 迅速从挂起状态结束;并且套接字上有数据可读时,epoll_wait 也迅速结束挂起状态,这时候通过 read 可以读取套接字接收缓冲区上的数据。
|
$telnet 127.0.0.1 43211
|
|
Trying 127.0.0.1...
|
|
Connected to 127.0.0.1.
|
|
Escape character is '^]'.
|
|
fasfsafas
|
|
snfsfnsnf
|
|
^]
|
|
telnet> quit
|
|
Connection closed.
|
edge-triggered VS level-triggered
对于 edge-triggered 和 level-triggered, 官方的说法是一个是边缘触发,一个是条件触发。也有文章从电子脉冲角度来解读的,总体上,给初学者的带来的感受是理解上有困难。
我在文稿里面给了两个程序,我们用这个程序来说明一下这两者之间的不同。
在这两个程序里,即使已连接套接字上有数据可读,我们也不调用 read 函数去读,只是简单地打印出一句话。
第一个程序我们设置为 edge-triggered,即边缘触发。开启这个服务器程序,用 telnet 连接上,输入一些字符,我们看到,服务器端只从 epoll_wait 中苏醒过一次,就是第一次有数据可读的时候。
|
$./epoll02
|
|
epoll_wait wakeup
|
|
epoll_wait wakeup
|
|
get event on socket fd == 5
|
|
$telnet 127.0.0.1 43211
|
|
Trying 127.0.0.1...
|
|
Connected to 127.0.0.1.
|
|
Escape character is '^]'.
|
|
asfafas
|
第二个程序我们设置为 level-triggered,即条件触发。然后按照同样的步骤来一次,观察服务器端,这一次我们可以看到,服务器端不断地从 epoll_wait 中苏醒,告诉我们有数据需要读取。
|
$./epoll03
|
|
epoll_wait wakeup
|
|
epoll_wait wakeup
|
|
get event on socket fd == 5
|
|
epoll_wait wakeup
|
|
get event on socket fd == 5
|
|
epoll_wait wakeup
|
|
get event on socket fd == 5
|
|
epoll_wait wakeup
|
|
get event on socket fd == 5
|
|
...
|
这就是两者的区别,条件触发的意思是只要满足事件的条件,比如有数据需要读,就一直不断地把这个事件传递给用户;而边缘触发的意思是只有第一次满足条件的时候才触发,之后就不会再传递同样的事件了。
一般我们认为,边缘触发的效率比条件触发的效率要高,这一点也是 epoll 的杀手锏之一。
epoll 的历史
早在 Linux 实现 epoll 之前,Windows 系统就已经在 1994 年引入了 IOCP,这是一个异步 I/O 模型,用来支持高并发的网络 I/O,而著名的 FreeBSD 在 2000 年引入了 Kqueue——一个 I/O 事件分发框架。
Linux 在 2002 年引入了 epoll,不过相关工作的讨论和设计早在 2000 年就开始了。如果你感兴趣的话,可以http://lkml.iu.edu/hypermail/linux/kernel/0010.3/0003.html"> 点击这里看一下里面的讨论。
为什么 Linux 不把 FreeBSD 的 kqueue 直接移植过来,而是另辟蹊径创立了 epoll 呢?
让我们先看下 kqueue 的用法,kqueue 也需要先创建一个名叫 kqueue 的对象,然后通过这个对象,调用 kevent 函数增加感兴趣的事件,同时,也是通过这个 kevent 函数来等待事件的发生。
|
int kqueue(void);
|
|
int kevent(int kq, const struct kevent *changelist, int nchanges,
|
|
struct kevent *eventlist, int nevents,
|
|
const struct timespec *timeout);
|
|
void EV_SET(struct kevent *kev, uintptr_t ident, short filter,
|
|
u_short flags, u_int fflags, intptr_t data, void *udata);
|
|
|
|
struct kevent {
|
|
uintptr_t ident; /* identifier (e.g., file descriptor) */
|
|
short filter; /* filter type (e.g., EVFILT_READ) */
|
|
u_short flags; /* action flags (e.g., EV_ADD) */
|
|
u_int fflags; /* filter-specific flags */
|
|
intptr_t data; /* filter-specific data */
|
|
void *udata; /* opaque user data */
|
|
};
|
Linus 在他最初的设想里,提到了这么一句话,也就是说他觉得类似 select 或 poll 的数组方式是可以的,而队列方式则是不可取的。
So sticky arrays of events are good, while queues are bad. Let’s take that as one of the fundamentals.
在最初的设计里,Linus 等于把 keque 里面的 kevent 函数拆分了两个部分,一部分负责事件绑定,通过 bind_event 函数来实现;另一部分负责事件等待,通过 get_events 来实现。
|
struct event {
|
|
unsigned long id; /* file descriptor ID the event is on */
|
|
unsigned long event; /* bitmask of active events */
|
|
};
|
|
|
|
int bind_event(int fd, struct event *event);
|
|
int get_events(struct event * event_array, int maxnr, struct timeval *tmout);
|
和最终的 epoll 实现相比,前者类似 epoll_ctl,后者类似 epoll_wait,不过原始的设计里没有考虑到创建 epoll 句柄,在最终的实现里增加了 epoll_create,支持了 epoll 句柄的创建。
2002 年,epoll 最终在 Linux 2.5.44 中首次出现,在 2.6 中趋于稳定,为 Linux 的高性能网络 I/O 画上了一段句号。
总结
Linux 中 epoll 的出现,为高性能网络编程补齐了最后一块拼图。epoll 通过改进的接口设计,避免了用户态 - 内核态频繁的数据拷贝,大大提高了系统性能。在使用 epoll 的时候,我们一定要理解条件触发和边缘触发两种模式。条件触发的意思是只要满足事件的条件,比如有数据需要读,就一直不断地把这个事件传递给用户;而边缘触发的意思是只有第一次满足条件的时候才触发,之后就不会再传递同样的事件了。
分别使用了 fork 进程和 pthread 线程来处理多并发,这两种技术使用简单,但是性能却会随着并发数的上涨而快速下降,并不能满足极端高并发的需求。就像第 24 讲中讲到的一样,这个时候我们需要寻找更好的解决之道,这个解决之道基本的思想就是 I/O 事件分发。
关于文稿中的代码,你可以去GitHub上查看或下载完整代码。
重温事件驱动
基于事件的程序设计: GUI、Web
事件驱动的好处是占用资源少,效率高,可扩展性强,是支持高性能高并发的不二之选。
如果你熟悉 GUI 编程的话,你就会知道,GUI 设定了一系列的控件,如 Button、Label、文本框等,当我们设计基于控件的程序时,一般都会给 Button 的点击安排一个函数,类似这样:
|
// 按钮点击的事件处理
|
|
void onButtonClick(){
|
|
|
|
}
|
这个设计的思想是,一个无限循环的事件分发线程在后台运行,一旦用户在界面上产生了某种操作,例如点击了某个 Button,或者点击了某个文本框,一个事件会被产生并放置到事件队列中,这个事件会有一个类似前面的 onButtonClick 回调函数。事件分发线程的任务,就是为每个发生的事件找到对应的事件回调函数并执行它。这样,一个基于事件驱动的 GUI 程序就可以完美地工作了。
还有一个类似的例子是 Web 编程领域。同样的,Web 程序会在 Web 界面上放置各种界面元素,例如 Label、文本框、按钮等,和 GUI 程序类似,给感兴趣的界面元素设计 JavaScript 回调函数,当用户操作时,对应的 JavaScript 回调函数会被执行,完成某个计算或操作。这样,一个基于事件驱动的 Web 程序就可以在浏览器中完美地工作了。
在第 24 讲中,我们已经提到,通过使用 poll、epoll 等 I/O 分发技术,可以设计出基于套接字的事件驱动程序,从而满足高性能、高并发的需求。
事件驱动模型,也被叫做反应堆模型(reactor),或者是 Event loop 模型。这个模型的核心有两点。
第一,它存在一个无限循环的事件分发线程,或者叫做 reactor 线程、Event loop 线程。这个事件分发线程的背后,就是 poll、epoll 等 I/O 分发技术的使用。
第二,所有的 I/O 操作都可以抽象成事件,每个事件必须有回调函数来处理。acceptor 上有连接建立成功、已连接套接字上发送缓冲区空出可以写、通信管道 pipe 上有数据可以读,这些都是一个个事件,通过事件分发,这些事件都可以一一被检测,并调用对应的回调函数加以处理。
几种 I/O 模型和线程模型设计
任何一个网络程序,所做的事情可以总结成下面几种:
- read:从套接字收取数据;
- decode:对收到的数据进行解析;
- compute:根据解析之后的内容,进行计算和处理;
- encode:将处理之后的结果,按照约定的格式进行编码;
- send:最后,通过套接字把结果发送出去。
这几个过程和套接字最相关的是 read 和 send 这两种。接下来,我们总结一下已经学过的几种支持多并发的网络编程技术,引出我们今天的话题,使用 poll 单线程处理所有 I/O。
fork
第 25 讲中,我们使用 fork 来创建子进程,为每个到达的客户连接服务。文稿中的这张图很好地解释了这个设计模式,可想而知的是,随着客户数的变多,fork 的子进程也越来越多,即使客户和服务器之间的交互比较少,这样的子进程也不能被销毁,一直需要存在。使用 fork 的方式处理非常简单,它的缺点是处理效率不高,fork 子进程的开销太大。

pthread
第 26 讲中,我们使用了 pthread_create 创建子线程,因为线程是比进程更轻量级的执行单位,所以它的效率相比 fork 的方式,有一定的提高。但是,每次创建一个线程的开销仍然是不小的,因此,引入了线程池的概念,预先创建出一个线程池,在每次新连接达到时,从线程池挑选出一个线程为之服务,很好地解决了线程创建的开销。但是,这个模式还是没有解决空闲连接占用资源的问题,如果一个连接在一定时间内没有数据交互,这个连接还是要占用一定的线程资源,直到这个连接消亡为止。

single reactor thread
前面讲到,事件驱动模式是解决高性能、高并发比较好的一种模式。为什么呢?
因为这种模式是符合大规模生产的需求的。我们的生活中遍地都是类似的模式。比如你去咖啡店喝咖啡,你点了一杯咖啡在一旁喝着,服务员也不会管你,等你有续杯需求的时候,再去和服务员提(触发事件), 服务员满足了你的需求,你就继续可以喝着咖啡玩手机。整个柜台的服务方式就是一个事件驱动的方式。
我在文稿中放了一张图解释了这一讲的设计模式。一个 reactor 线程上同时负责分发 acceptor 的事件、已连接套接字的 I/O 事件。

single reactor thread + worker threads
但是上述的设计模式有一个问题,和 I/O 事件处理相比,应用程序的业务逻辑处理是比较耗时的,比如 XML 文件的解析、数据库记录的查找、文件资料的读取和传输、计算型工作的处理等,这些工作相对而言比较独立,它们会拖慢整个反应堆模式的执行效率。
所以,将这些 decode、compute、enode 型工作放置到另外的线程池中,和反应堆线程解耦,是一个比较明智的选择。我在文稿中放置了这样的一张图。反应堆线程只负责处理 I/O 相关的工作,业务逻辑相关的工作都被裁剪成一个一个的小任务,放到线程池里由空闲的线程来执行。当结果完成后,再交给反应堆线程,由反应堆线程通过套接字将结果发送出去。

样例程序
从今天开始,我们会接触到为本课程量身定制的网络编程框架。使用这个网络编程框架的样例程序已经放到文稿中:
|
#include <lib/acceptor.h>
|
|
#include "lib/common.h"
|
|
#include "lib/event_loop.h"
|
|
#include "lib/tcp_server.h"
|
|
|
|
char rot13_char(char c) {
|
|
if ((c >= 'a' && c <= 'm') || (c >= 'A' && c <= 'M'))
|
|
return c + 13;
|
|
else if ((c >= 'n' && c <= 'z') || (c >= 'N' && c <= 'Z'))
|
|
return c - 13;
|
|
else
|
|
return c;
|
|
}
|
|
|
|
// 连接建立之后的 callback
|
|
int onConnectionCompleted(struct tcp_connection *tcpConnection) {
|
|
printf("connection completed\n");
|
|
return 0;
|
|
}
|
|
|
|
// 数据读到 buffer 之后的 callback
|
|
int onMessage(struct buffer *input, struct tcp_connection *tcpConnection) {
|
|
printf("get message from tcp connection %s\n", tcpConnection->name);
|
|
printf("%s", input->data);
|
|
|
|
struct buffer *output = buffer_new();
|
|
int size = buffer_readable_size(input);
|
|
for (int i = 0; i < size; i++) {
|
|
buffer_append_char(output, rot13_char(buffer_read_char(input)));
|
|
}
|
|
tcp_connection_send_buffer(tcpConnection, output);
|
|
return 0;
|
|
}
|
|
|
|
// 数据通过 buffer 写完之后的 callback
|
|
int onWriteCompleted(struct tcp_connection *tcpConnection) {
|
|
printf("write completed\n");
|
|
return 0;
|
|
}
|
|
|
|
// 连接关闭之后的 callback
|
|
int onConnectionClosed(struct tcp_connection *tcpConnection) {
|
|
printf("connection closed\n");
|
|
return 0;
|
|
}
|
|
|
|
int main(int c, char **v) {
|
|
// 主线程 event_loop
|
|
struct event_loop *eventLoop = event_loop_init();
|
|
|
|
// 初始化 acceptor
|
|
struct acceptor *acceptor = acceptor_init(SERV_PORT);
|
|
|
|
// 初始 tcp_server,可以指定线程数目,如果线程是 0,就只有一个线程,既负责 acceptor,也负责 I/O
|
|
struct TCPserver *tcpServer = tcp_server_init(eventLoop, acceptor, onConnectionCompleted, onMessage,
|
|
onWriteCompleted, onConnectionClosed, 0);
|
|
tcp_server_start(tcpServer);
|
|
|
|
// main thread for acceptor
|
|
event_loop_run(eventLoop);
|
|
}
|
这个程序的 main 函数部分只有几行, 因为是第一次接触到,稍微展开介绍一下。
第 49 行创建了一个 event_loop,即 reactor 对象,这个 event_loop 和线程相关联,每个 event_loop 在线程里执行的是一个无限循环,以便完成事件的分发。
第 52 行初始化了 acceptor,用来监听在某个端口上。
第 55 行创建了一个 TCPServer,创建的时候可以指定线程数目,这里线程是 0,就只有一个线程,既负责 acceptor 的连接处理,也负责已连接套接字的 I/O 处理。这里比较重要的是传入了几个回调函数,分别对应了连接建立完成、数据读取完成、数据发送完成、连接关闭完成几种操作,通过回调函数,让业务程序可以聚焦在业务层开发。
第 57 行开启监听。
第 60 行运行 event_loop 无限循环,等待 acceptor 上有连接建立、新连接上有数据可读等。
样例程序结果
运行这个服务器程序,开启两个 telnet 客户端,我们看到服务器端的输出如下:
|
$./poll-server-onethread
|
|
[msg] set poll as dispatcher
|
|
[msg] add channel fd == 4, main thread
|
|
[msg] poll added channel fd==4
|
|
[msg] add channel fd == 5, main thread
|
|
[msg] poll added channel fd==5
|
|
[msg] event loop run, main thread
|
|
[msg] get message channel i==1, fd==5
|
|
[msg] activate channel fd == 5, revents=2, main thread
|
|
[msg] new connection established, socket == 6
|
|
connection completed
|
|
[msg] add channel fd == 6, main thread
|
|
[msg] poll added channel fd==6
|
|
[msg] get message channel i==2, fd==6
|
|
[msg] activate channel fd == 6, revents=2, main thread
|
|
get message from tcp connection connection-6
|
|
afadsfaf
|
|
[msg] get message channel i==2, fd==6
|
|
[msg] activate channel fd == 6, revents=2, main thread
|
|
get message from tcp connection connection-6
|
|
afadsfaf
|
|
fdafasf
|
|
[msg] get message channel i==1, fd==5
|
|
[msg] activate channel fd == 5, revents=2, main thread
|
|
[msg] new connection established, socket == 7
|
|
connection completed
|
|
[msg] add channel fd == 7, main thread
|
|
[msg] poll added channel fd==7
|
|
[msg] get message channel i==3, fd==7
|
|
[msg] activate channel fd == 7, revents=2, main thread
|
|
get message from tcp connection connection-7
|
|
sfasggwqe
|
|
[msg] get message channel i==3, fd==7
|
|
[msg] activate channel fd == 7, revents=2, main thread
|
|
[msg] poll delete channel fd==7
|
|
connection closed
|
|
[msg] get message channel i==2, fd==6
|
|
[msg] activate channel fd == 6, revents=2, main thread
|
|
[msg] poll delete channel fd==6
|
|
connection closed
|
这里自始至终都只有一个 main thread 在工作,可见,单线程的 reactor 处理多个连接时也可以表现良好。
总结
这一讲我们总结了几种不同的 I/O 模型和线程模型设计,并比较了各自不同的优缺点。从这一讲开始,我们将使用自己编写的编程框架来完成业务开发,这一讲使用了 poll 来处理所有的 I/O 事件,在下一讲里,我们将会看到如何把 acceptor 的连接事件和已连接套接字的 I/O 事件交由不同的线程处理,而这个分离,不过是在应用程序层简单的参数配置而已。
在前面的第 27 讲中,我们引入了 reactor 反应堆模式,并且让 reactor 反应堆同时分发 Acceptor 上的连接建立事件和已建立连接的 I/O 事件。
我们仔细想想这种模式,在发起连接请求的客户端非常多的情况下,有一个地方是有问题的,那就是单 reactor 线程既分发连接建立,又分发已建立连接的 I/O,有点忙不过来,在实战中的表现可能就是客户端连接成功率偏低。
再者,新的硬件技术不断发展,多核多路 CPU 已经得到极大的应用,单 reactor 反应堆模式看着大把的 CPU 资源却不用,有点可惜。
这一讲我们就将 acceptor 上的连接建立事件和已建立连接的 I/O 事件分离,形成所谓的主 - 从 reactor 模式。
主 - 从 reactor 模式
文章下面的这张图描述了主 - 从 reactor 模式是如何工作的。
主 - 从这个模式的核心思想是,主反应堆线程只负责分发 Acceptor 连接建立,已连接套接字上的 I/O 事件交给 sub-reactor 负责分发。其中 sub-reactor 的数量,可以根据 CPU 的核数来灵活设置。
比如一个四核 CPU,我们可以设置 sub-reactor 为 4。相当于有 4 个身手不凡的反应堆线程同时在工作,这大大增强了 I/O 分发处理的效率。而且,同一个套接字事件分发只会出现在一个反应堆线程中,这会大大减少并发处理的锁开销。

我来解释一下这张图,我们的主反应堆线程一直在感知连接建立的事件,如果有连接成功建立,主反应堆线程通过 accept 方法获取已连接套接字,接下来会按照一定的算法选取一个从反应堆线程,并把已连接套接字加入到选择好的从反应堆线程中。
主反应堆线程唯一的工作,就是调用 accept 获取已连接套接字,以及将已连接套接字加入到从反应堆线程中。不过,这里还有一个小问题,主反应堆线程和从反应堆线程,是两个不同的线程,如何把已连接套接字加入到另外一个线程中呢?更令人沮丧的是,此时从反应堆线程或许处于事件分发的无限循环之中,在这种情况下应该怎么办呢?
我在这里先卖个关子,这是高性能网络程序框架要解决的问题。在实战篇里,我将为这些问题一一解开答案。
主 - 从 reactor+worker threads 模式
如果说主 - 从 reactor 模式解决了 I/O 分发的高效率问题,那么 work threads 就解决了业务逻辑和 I/O 分发之间的耦合问题。把这两个策略组装在一起,就是实战中普遍采用的模式。大名鼎鼎的 Netty,就是把这种模式发挥到极致的一种实现。不过要注意 Netty 里面提到的 worker 线程,其实就是我们这里说的从 reactor 线程,并不是处理具体业务逻辑的 worker 线程。
下面贴的一段代码就是常见的 Netty 初始化代码,这里 Boss Group 就是 acceptor 主反应堆,workerGroup 就是从反应堆。而处理业务逻辑的线程,通常都是通过使用 Netty 的程序开发者进行设计和定制,一般来说,业务逻辑线程需要从 workerGroup 线程中分离,以便支持更高的并发度。
|
public final class TelnetServer {
|
|
static final int PORT = Integer.parseInt(System.getProperty("port", SSL? "8992" : "8023"));
|
|
|
|
public static void main(String[] args) throws Exception {
|
|
// 产生一个 reactor 线程,只负责 accetpor 的对应处理
|
|
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
|
|
// 产生一个 reactor 线程,负责处理已连接套接字的 I/O 事件分发
|
|
EventLoopGroup workerGroup = new NioEventLoopGroup(1);
|
|
try {
|
|
// 标准的 Netty 初始,通过 serverbootstrap 完成线程池、channel 以及对应的 handler 设置,注意这里讲 bossGroup 和 workerGroup 作为参数设置
|
|
ServerBootstrap b = new ServerBootstrap();
|
|
b.group(bossGroup, workerGroup)
|
|
.channel(NioServerSocketChannel.class)
|
|
.handler(new LoggingHandler(LogLevel.INFO))
|
|
.childHandler(new TelnetServerInitializer(sslCtx));
|
|
|
|
// 开启两个 reactor 线程无限循环处理
|
|
b.bind(PORT).sync().channel().closeFuture().sync();
|
|
} finally {
|
|
bossGroup.shutdownGracefully();
|
|
workerGroup.shutdownGracefully();
|
|
}
|
|
}
|
|
}
|

这张图解释了主 - 从反应堆下加上 worker 线程池的处理模式。
主 - 从反应堆跟上面介绍的做法是一样的。和上面不一样的是,这里将 decode、compute、encode 等 CPU 密集型的工作从 I/O 线程中拿走,这些工作交给 worker 线程池来处理,而且这些工作拆分成了一个个子任务进行。encode 之后完成的结果再由 sub-reactor 的 I/O 线程发送出去。
样例程序
|
#include <lib/acceptor.h>
|
|
#include "lib/common.h"
|
|
#include "lib/event_loop.h"
|
|
#include "lib/tcp_server.h"
|
|
|
|
char rot13_char(char c) {
|
|
if ((c >= 'a' && c <= 'm') || (c >= 'A' && c <= 'M'))
|
|
return c + 13;
|
|
else if ((c >= 'n' && c <= 'z') || (c >= 'N' && c <= 'Z'))
|
|
return c - 13;
|
|
else
|
|
return c;
|
|
}
|
|
|
|
// 连接建立之后的 callback
|
|
int onConnectionCompleted(struct tcp_connection *tcpConnection) {
|
|
printf("connection completed\n");
|
|
return 0;
|
|
}
|
|
|
|
// 数据读到 buffer 之后的 callback
|
|
int onMessage(struct buffer *input, struct tcp_connection *tcpConnection) {
|
|
printf("get message from tcp connection %s\n", tcpConnection->name);
|
|
printf("%s", input->data);
|
|
|
|
struct buffer *output = buffer_new();
|
|
int size = buffer_readable_size(input);
|
|
for (int i = 0; i < size; i++) {
|
|
buffer_append_char(output, rot13_char(buffer_read_char(input)));
|
|
}
|
|
tcp_connection_send_buffer(tcpConnection, output);
|
|
return 0;
|
|
}
|
|
|
|
// 数据通过 buffer 写完之后的 callback
|
|
int onWriteCompleted(struct tcp_connection *tcpConnection) {
|
|
printf("write completed\n");
|
|
return 0;
|
|
}
|
|
|
|
// 连接关闭之后的 callback
|
|
int onConnectionClosed(struct tcp_connection *tcpConnection) {
|
|
printf("connection closed\n");
|
|
return 0;
|
|
}
|
|
|
|
int main(int c, char **v) {
|
|
// 主线程 event_loop
|
|
struct event_loop *eventLoop = event_loop_init();
|
|
|
|
// 初始化 acceptor
|
|
struct acceptor *acceptor = acceptor_init(SERV_PORT);
|
|
|
|
// 初始 tcp_server,可以指定线程数目,这里线程是 4,说明是一个 acceptor 线程,4 个 I/O 线程,没一个 I/O 线程
|
|
//tcp_server 自己带一个 event_loop
|
|
struct TCPserver *tcpServer = tcp_server_init(eventLoop, acceptor, onConnectionCompleted, onMessage,
|
|
onWriteCompleted, onConnectionClosed, 4);
|
|
tcp_server_start(tcpServer);
|
|
|
|
// main thread for acceptor
|
|
event_loop_run(eventLoop);
|
|
}
|
我们的样例程序几乎和第 27 讲的一样,唯一的不同是在创建 TCPServer 时,线程的数量设置不再是 0,而是 4。这里线程是 4,说明是一个主 acceptor 线程,4 个从 reactor 线程,每一个线程都跟一个 event_loop 一一绑定。
你可能会问,这么简单就完成了主、从线程的配置?
答案是 YES。这其实是设计框架需要考虑的地方,一个框架不仅要考虑性能、扩展性,也需要考虑可用性。可用性部分就是程序开发者如何使用框架。如果我是一个开发者,我肯定关心框架的使用方式是不是足够方便,配置是不是足够灵活等。
像这里,可以根据需求灵活地配置主、从反应堆线程,就是一个易用性的体现。当然,因为时间有限,我没有考虑 woker 线程的部分,这部分其实应该是应用程序自己来设计考虑。网络编程框架通过回调函数暴露了交互的接口,这里应用程序开发者完全可以在 onMessage 方法里面获取一个子线程来处理 encode、compute 和 encode 的工作,像下面的示范代码一样。
|
// 数据读到 buffer 之后的 callback
|
|
int onMessage(struct buffer *input, struct tcp_connection *tcpConnection) {
|
|
printf("get message from tcp connection %s\n", tcpConnection->name);
|
|
printf("%s", input->data);
|
|
// 取出一个线程来负责 decode、compute 和 encode
|
|
struct buffer *output = thread_handle(input);
|
|
// 处理完之后再通过 reactor I/O 线程发送数据
|
|
tcp_connection_send_buffer(tcpConnection, output);
|
|
return
|
样例程序结果
我们启动这个服务器端程序,你可以从服务器端的输出上看到使用了 poll 作为事件分发方式。
多打开几个 telnet 客户端交互,main-thread 只负责新的连接建立,每个客户端数据的收发由不同的子线程 Thread-1、Thread-2、Thread-3 和 Thread-4 来提供服务。
这里由于使用了子线程进行 I/O 处理,主线程可以专注于新连接处理,从而大大提高了客户端连接成功率。
|
$./poll-server-multithreads
|
|
[msg] set poll as dispatcher
|
|
[msg] add channel fd == 4, main thread
|
|
[msg] poll added channel fd==4
|
|
[msg] set poll as dispatcher
|
|
[msg] add channel fd == 7, main thread
|
|
[msg] poll added channel fd==7
|
|
[msg] event loop thread init and signal, Thread-1
|
|
[msg] event loop run, Thread-1
|
|
[msg] event loop thread started, Thread-1
|
|
[msg] set poll as dispatcher
|
|
[msg] add channel fd == 9, main thread
|
|
[msg] poll added channel fd==9
|
|
[msg] event loop thread init and signal, Thread-2
|
|
[msg] event loop run, Thread-2
|
|
[msg] event loop thread started, Thread-2
|
|
[msg] set poll as dispatcher
|
|
[msg] add channel fd == 11, main thread
|
|
[msg] poll added channel fd==11
|
|
[msg] event loop thread init and signal, Thread-3
|
|
[msg] event loop thread started, Thread-3
|
|
[msg] set poll as dispatcher
|
|
[msg] event loop run, Thread-3
|
|
[msg] add channel fd == 13, main thread
|
|
[msg] poll added channel fd==13
|
|
[msg] event loop thread init and signal, Thread-4
|
|
[msg] event loop run, Thread-4
|
|
[msg] event loop thread started, Thread-4
|
|
[msg] add channel fd == 5, main thread
|
|
[msg] poll added channel fd==5
|
|
[msg] event loop run, main thread
|
|
[msg] get message channel i==1, fd==5
|
|
[msg] activate channel fd == 5, revents=2, main thread
|
|
[msg] new connection established, socket == 14
|
|
connection completed
|
|
[msg] get message channel i==0, fd==7
|
|
[msg] activate channel fd == 7, revents=2, Thread-1
|
|
[msg] wakeup, Thread-1
|
|
[msg] add channel fd == 14, Thread-1
|
|
[msg] poll added channel fd==14
|
|
[msg] get message channel i==1, fd==14
|
|
[msg] activate channel fd == 14, revents=2, Thread-1
|
|
get message from tcp connection connection-14
|
|
fasfas
|
|
[msg] get message channel i==1, fd==14
|
|
[msg] activate channel fd == 14, revents=2, Thread-1
|
|
get message from tcp connection connection-14
|
|
fasfas
|
|
asfa
|
|
[msg] get message channel i==1, fd==5
|
|
[msg] activate channel fd == 5, revents=2, main thread
|
|
[msg] new connection established, socket == 15
|
|
connection completed
|
|
[msg] get message channel i==0, fd==9
|
|
[msg] activate channel fd == 9, revents=2, Thread-2
|
|
[msg] wakeup, Thread-2
|
|
[msg] add channel fd == 15, Thread-2
|
|
[msg] poll added channel fd==15
|
|
[msg] get message channel i==1, fd==15
|
|
[msg] activate channel fd == 15, revents=2, Thread-2
|
|
get message from tcp connection connection-15
|
|
afasdfasf
|
|
[msg] get message channel i==1, fd==15
|
|
[msg] activate channel fd == 15, revents=2, Thread-2
|
|
get message from tcp connection connection-15
|
|
afasdfasf
|
|
safsafa
|
|
[msg] get message channel i==1, fd==15
|
|
[msg] activate channel fd == 15, revents=2, Thread-2
|
|
[msg] poll delete channel fd==15
|
|
connection closed
|
|
[msg] get message channel i==1, fd==5
|
|
[msg] activate channel fd == 5, revents=2, main thread
|
|
[msg] new connection established, socket == 16
|
|
connection completed
|
|
[msg] get message channel i==0, fd==11
|
|
[msg] activate channel fd == 11, revents=2, Thread-3
|
|
[msg] wakeup, Thread-3
|
|
[msg] add channel fd == 16, Thread-3
|
|
[msg] poll added channel fd==16
|
|
[msg] get message channel i==1, fd==16
|
|
[msg] activate channel fd == 16, revents=2, Thread-3
|
|
get message from tcp connection connection-16
|
|
fdasfasdf
|
|
[msg] get message channel i==1, fd==14
|
|
[msg] activate channel fd == 14, revents=2, Thread-1
|
|
[msg] poll delete channel fd==14
|
|
connection closed
|
|
[msg] get message channel i==1, fd==5
|
|
[msg] activate channel fd == 5, revents=2, main thread
|
|
[msg] new connection established, socket == 17
|
|
connection completed
|
|
[msg] get message channel i==0, fd==13
|
|
[msg] activate channel fd == 13, revents=2, Thread-4
|
|
[msg] wakeup, Thread-4
|
|
[msg] add channel fd == 17, Thread-4
|
|
[msg] poll added channel fd==17
|
|
[msg] get message channel i==1, fd==17
|
|
[msg] activate channel fd == 17, revents=2, Thread-4
|
|
get message from tcp connection connection-17
|
|
qreqwrq
|
|
[msg] get message channel i==1, fd==16
|
|
[msg] activate channel fd == 16, revents=2, Thread-3
|
|
[msg] poll delete channel fd==16
|
|
connection closed
|
|
[msg] get message channel i==1, fd==5
|
|
[msg] activate channel fd == 5, revents=2, main thread
|
|
[msg] new connection established, socket == 18
|
|
connection completed
|
|
[msg] get message channel i==0, fd==7
|
|
[msg] activate channel fd == 7, revents=2, Thread-1
|
|
[msg] wakeup, Thread-1
|
|
[msg] add channel fd == 18, Thread-1
|
|
[msg] poll added channel fd==18
|
|
[msg] get message channel i==1, fd==18
|
|
[msg] activate channel fd == 18, revents=2, Thread-1
|
|
get message from tcp connection connection-18
|
|
fasgasdg
|
|
^C
|
总结
本讲主要讲述了主从 reactor 模式,主从 reactor 模式中,主 reactor 只负责连接建立的处理,而把已连接套接字的 I/O 事件分发交给从 reactor 线程处理,这大大提高了客户端连接的处理能力。从 Netty 的实现上来看,也遵循了这一原则。
在前面的第27讲和第28讲中,我介绍了我们基于poll事件发送的反应堆反应模式,以及主从反应堆模式。知道,和poll相比,Linux提供的epoll是一种更为严重的事件快速发生机制。在这个讲里,我们将切换到epoll实现的主反应堆模式,并分析一下为什么epoll的性能会强于poll等传统的事件发生机制。
如何切换到epoll
我已经将所有的代码已经放置到GitHub上,你可以自行查看或下载。
我们的网络编程框架是可以同时支持poll和epoll机制的,那么如何开启epoll的支持呢?
lib/event_loop.c 文件的 event_loop_init_with_name 函数是关键,可以,这里是通过宏 EPOLL_ENABLE 来决定是使用 epoll 还是 poll 的。
|
struct event_loop *event_loop_init_with_name(char *thread_name) {
|
|
...
|
|
#ifdef EPOLL_ENABLE
|
|
yolanda_msgx("set epoll as dispatcher, %s", eventLoop->thread_name);
|
|
eventLoop->eventDispatcher = &epoll_dispatcher;
|
|
#else
|
|
yolanda_msgx("set poll as dispatcher, %s", eventLoop->thread_name);
|
|
eventLoop->eventDispatcher = &poll_dispatcher;
|
|
#endif
|
|
eventLoop->event_dispatcher_data = eventLoop->eventDispatcher->init(eventLoop);
|
|
...
|
|
}
|
在根目录下的CMakeLists.txt文件里,引入CheckSymbolExists,如果系统里有epoll_create函数和sys/epoll.h,就自动开启EPOLL_ENABLE。如果没有,EPOLL_ENABLE就不会开启,自动使用poll作为默认的事件退出机制。
|
# check epoll and add config.h for the macro compilation
|
|
include(CheckSymbolExists)
|
|
check_symbol_exists(epoll_create "sys/epoll.h" EPOLL_EXISTS)
|
|
if (EPOLL_EXISTS)
|
|
# Linux 下设置为 epoll
|
|
set(EPOLL_ENABLE 1 CACHE INTERNAL "enable epoll")
|
|
|
|
# Linux 下也设置为 poll
|
|
# set(EPOLL_ENABLE "" CACHE INTERNAL "not enable epoll")
|
|
else ()
|
|
set(EPOLL_ENABLE "" CACHE INTERNAL "not enable epoll")
|
|
endif ()
|
但是,为了让编译器使用到这个宏,需要让 CMake 往 config.h 文件里写入这个宏的最终值,configure_file 命令就是起这个作用的。其中 config.h.cmake 是一个模板文件,已经预先在根目录下创建了。同时还需要让编译器包含这个 config.h 文件。include_directories 可以帮助我们完成这个目标。
|
configure_file(${CMAKE_CURRENT_SOURCE_DIR}/config.h.cmake
|
|
${CMAKE_CURRENT_BINARY_DIR}/include/config.h)
|
|
|
|
include_directories(${CMAKE_CURRENT_BINARY_DIR}/include)
|
这样,在Linux下,就会默认使用epoll作为事件分发。
那么前面的27讲和28讲中的程序案例如何改为使用poll的呢?
我们可以修改CMakeLists.txt文件,把Linux下设置为 poll 的那段注释下的命令打开,同时关闭掉语义命令设置为 1 的就可以了。下面就是具体的示例代码。
|
# check epoll and add config.h for the macro compilation
|
|
include(CheckSymbolExists)
|
|
check_symbol_exists(epoll_create "sys/epoll.h" EPOLL_EXISTS)
|
|
if (EPOLL_EXISTS)
|
|
# Linux 下也设置为 poll
|
|
set(EPOLL_ENABLE "" CACHE INTERNAL "not enable epoll")
|
|
else ()
|
|
set(EPOLL_ENABLE "" CACHE INTERNAL "not enable epoll")
|
|
endif (
|
不管怎么样,现在我们已经得到了一个Linux下使用epoll作为事件分发的版本,现在就让我们用它来编写程序吧。
样例程序
我们的样例程序和第28讲的一模一样,现在只是我们的事件分发机制从poll切换到了epoll。
|
#include <lib/acceptor.h>
|
|
#include "lib/common.h"
|
|
#include "lib/event_loop.h"
|
|
#include "lib/tcp_server.h"
|
|
|
|
char rot13_char(char c) {
|
|
if ((c >= 'a' && c <= 'm') || (c >= 'A' && c <= 'M'))
|
|
return c + 13;
|
|
else if ((c >= 'n' && c <= 'z') || (c >= 'N' && c <= 'Z'))
|
|
return c - 13;
|
|
else
|
|
return c;
|
|
}
|
|
|
|
// 连接建立之后的 callback
|
|
int onConnectionCompleted(struct tcp_connection *tcpConnection) {
|
|
printf("connection completed\n");
|
|
return 0;
|
|
}
|
|
|
|
// 数据读到 buffer 之后的 callback
|
|
int onMessage(struct buffer *input, struct tcp_connection *tcpConnection) {
|
|
printf("get message from tcp connection %s\n", tcpConnection->name);
|
|
printf("%s", input->data);
|
|
|
|
struct buffer *output = buffer_new();
|
|
int size = buffer_readable_size(input);
|
|
for (int i = 0; i < size; i++) {
|
|
buffer_append_char(output, rot13_char(buffer_read_char(input)));
|
|
}
|
|
tcp_connection_send_buffer(tcpConnection, output);
|
|
return 0;
|
|
}
|
|
|
|
// 数据通过 buffer 写完之后的 callback
|
|
int onWriteCompleted(struct tcp_connection *tcpConnection) {
|
|
printf("write completed\n");
|
|
return 0;
|
|
}
|
|
|
|
// 连接关闭之后的 callback
|
|
int onConnectionClosed(struct tcp_connection *tcpConnection) {
|
|
printf("connection closed\n");
|
|
return 0;
|
|
}
|
|
|
|
int main(int c, char **v) {
|
|
// 主线程 event_loop
|
|
struct event_loop *eventLoop = event_loop_init();
|
|
|
|
// 初始化 acceptor
|
|
struct acceptor *acceptor = acceptor_init(SERV_PORT);
|
|
|
|
// 初始 tcp_server,可以指定线程数目,这里线程是 4,说明是一个 acceptor 线程,4 个 I/O 线程,没一个 I/O 线程
|
|
//tcp_server 自己带一个 event_loop
|
|
struct TCPserver *tcpServer = tcp_server_init(eventLoop, acceptor, onConnectionCompleted, onMessage,
|
|
onWriteCompleted, onConnectionClosed, 4);
|
|
tcp_server_start(tcpServer);
|
|
|
|
// main thread for acceptor
|
|
event_loop_run(eventLoop);
|
|
}
|
关于这个程序,之前一直没有讲到的部分是蜡烛对象缓冲区。这实际上也是网络编程框架应该考虑的部分。
我们希望框架可以对应用程序套件配备读取和写入的部分,转而提供针对传感器对象的读取和写入操作。这样,从导入数据、处理异常、发送数据等操作都被类似缓冲区这样的对象所封装和增强,应用程序所做的事情就会变得更加简单,从缓冲区对象中可以获取已接收到的字节流再进行应用层处理,比如这里通过调用 buffer_read_char 函数从缓冲区中读取一个字节。
另外,框架也必须为应用程序提供设备导入的接口,接口的数据类型类似于这里的缓冲区对象,可以看到,这里先生获得了一个缓冲区对象,之后将编码后的结果填充到缓冲区对象里,最后调用 tcp_connection_send_buffer 将缓冲区对象里的数据通过设备导出出去。
这里像 onMessage、onConnectionClosed 几个回调函数都是运行在子反应堆线程中的,既然,刚才提到的生成缓冲区对象,编码部分的代码,是在反应堆线程中执行的。这其实也是回调函数的内涵,回调函数本身只是提供了类似 Handlder 的处理逻辑,具体执行是由事件队列线程,或者说是事件循环线程发起的。
框架通过层次抽象,让应用程序的开发者只需要看到回调函数,回调函数中的对象,也都是如 buffer 和 tcp_connection 这样封装过的对象,这样像导入、字节流等底层实现的细节就由框架来完成了。
框架帮助我们做了很多事情,那这些事情是如何实现的?在第四篇实战篇中,我们将一举揭开答案。如果你有兴趣,请先看看实现代码。
样例程序结果
启动服务器,可以是从屏幕输出上看到,使用epoll作为事件发送器。
|
$./epoll-server-multithreads
|
|
[msg] set epoll as dispatcher, main thread
|
|
[msg] add channel fd == 5, main thread
|
|
[msg] set epoll as dispatcher, Thread-1
|
|
[msg] add channel fd == 9, Thread-1
|
|
[msg] event loop thread init and signal, Thread-1
|
|
[msg] event loop run, Thread-1
|
|
[msg] event loop thread started, Thread-1
|
|
[msg] set epoll as dispatcher, Thread-2
|
|
[msg] add channel fd == 12, Thread-2
|
|
[msg] event loop thread init and signal, Thread-2
|
|
[msg] event loop run, Thread-2
|
|
[msg] event loop thread started, Thread-2
|
|
[msg] set epoll as dispatcher, Thread-3
|
|
[msg] add channel fd == 15, Thread-3
|
|
[msg] event loop thread init and signal, Thread-3
|
|
[msg] event loop run, Thread-3
|
|
[msg] event loop thread started, Thread-3
|
|
[msg] set epoll as dispatcher, Thread-4
|
|
[msg] add channel fd == 18, Thread-4
|
|
[msg] event loop thread init and signal, Thread-4
|
|
[msg] event loop run, Thread-4
|
|
[msg] event loop thread started, Thread-4
|
|
[msg] add channel fd == 6, main thread
|
|
[msg] event loop run, main thread
|
打开多个telnet客户端,连接上该服务器,通过屏幕输入和服务器端交互。
|
$telnet 127.0.0.1 43211
|
|
Trying 127.0.0.1...
|
|
Connected to 127.0.0.1.
|
|
Escape character is '^]'.
|
|
fafaf
|
|
snsns
|
|
^]
|
|
|
|
|
|
telnet> quit
|
|
Connection closed.
|
服务端不断显示从 epoll_wait 中返回处理 I/O 事件。
|
[msg] epoll_wait wakeup, main thread
|
|
[msg] get message channel fd==6 for read, main thread
|
|
[msg] activate channel fd == 6, revents=2, main thread
|
|
[msg] new connection established, socket == 19
|
|
connection completed
|
|
[msg] epoll_wait wakeup, Thread-1
|
|
[msg] get message channel fd==9 for read, Thread-1
|
|
[msg] activate channel fd == 9, revents=2, Thread-1
|
|
[msg] wakeup, Thread-1
|
|
[msg] add channel fd == 19, Thread-1
|
|
[msg] epoll_wait wakeup, Thread-1
|
|
[msg] get message channel fd==19 for read, Thread-1
|
|
[msg] activate channel fd == 19, revents=2, Thread-1
|
|
get message from tcp connection connection-19
|
|
afasf
|
|
[msg] epoll_wait wakeup, main thread
|
|
[msg] get message channel fd==6 for read, main thread
|
|
[msg] activate channel fd == 6, revents=2, main thread
|
|
[msg] new connection established, socket == 20
|
|
connection completed
|
|
[msg] epoll_wait wakeup, Thread-2
|
|
[msg] get message channel fd==12 for read, Thread-2
|
|
[msg] activate channel fd == 12, revents=2, Thread-2
|
|
[msg] wakeup, Thread-2
|
|
[msg] add channel fd == 20, Thread-2
|
|
[msg] epoll_wait wakeup, Thread-2
|
|
[msg] get message channel fd==20 for read, Thread-2
|
|
[msg] activate channel fd == 20, revents=2, Thread-2
|
|
get message from tcp connection connection-20
|
|
asfasfas
|
|
[msg] epoll_wait wakeup, Thread-2
|
|
[msg] get message channel fd==20 for read, Thread-2
|
|
[msg] activate channel fd == 20, revents=2, Thread-2
|
|
connection closed
|
|
[msg] epoll_wait wakeup, main thread
|
|
[msg] get message channel fd==6 for read, main thread
|
|
[msg] activate channel fd == 6, revents=2, main thread
|
|
[msg] new connection established, socket == 21
|
|
connection completed
|
|
[msg] epoll_wait wakeup, Thread-3
|
|
[msg] get message channel fd==15 for read, Thread-3
|
|
[msg] activate channel fd == 15, revents=2, Thread-3
|
|
[msg] wakeup, Thread-3
|
|
[msg] add channel fd == 21, Thread-3
|
|
[msg] epoll_wait wakeup, Thread-3
|
|
[msg] get message channel fd==21 for read, Thread-3
|
|
[msg] activate channel fd == 21, revents=2, Thread-3
|
|
get message from tcp connection connection-21
|
|
dfasfadsf
|
|
[msg] epoll_wait wakeup, Thread-1
|
|
[msg] get message channel fd==19 for read, Thread-1
|
|
[msg] activate channel fd == 19, revents=2, Thread-1
|
|
connection closed
|
|
[msg] epoll_wait wakeup, main thread
|
|
[msg] get message channel fd==6 for read, main thread
|
|
[msg] activate channel fd == 6, revents=2, main thread
|
|
[msg] new connection established, socket == 22
|
|
connection completed
|
|
[msg] epoll_wait wakeup, Thread-4
|
|
[msg] get message channel fd==18 for read, Thread-4
|
|
[msg] activate channel fd == 18, revents=2, Thread-4
|
|
[msg] wakeup, Thread-4
|
|
[msg] add channel fd == 22, Thread-4
|
|
[msg] epoll_wait wakeup, Thread-4
|
|
[msg] get message channel fd==22 for read, Thread-4
|
|
[msg] activate channel fd == 22, revents=2, Thread-4
|
|
get message from tcp connection connection-22
|
|
fafaf
|
|
[msg] epoll_wait wakeup, Thread-4
|
|
[msg] get message channel fd==22 for read, Thread-4
|
|
[msg] activate channel fd == 22, revents=2, Thread-4
|
|
connection closed
|
|
[msg] epoll_wait wakeup, Thread-3
|
|
[msg] get message channel fd==21 for read, Thread-3
|
|
[msg] activate channel fd == 21, revents=2, Thread-3
|
|
connection closed
|
其中主线程的 epoll_wait 仅处理接受器 JSON 的事件,表示已建立连接;反应 stack 子线程的 epoll_wait 主要处理已连接 JSON 的事件。 XML 中的该格式图详细解释了该部分逻辑。

epoll的性能分析
epoll的性能凭什么比poll还是select好呢?这要从两个角度来说明。
第一个角度是事件集合。在每次使用 poll 或 select 之前,都需要准备一个感兴趣的事件集合,系统内核获取事件集合,进行分析并在内核空间构建相应的数据结构来完成对事件集合的注册。而 epoll 则不然,epoll 维护了一个全局的事件集合,通过 epoll句柄,可以收集这个事件集合,增加、删除或修改这个事件集合里的一些元素。要知道在大多数情况下,事件集合的变化没有那么大,这样收集系统内核就需要每次重新扫描事件集合,构建内核空间数据结构。
第二个角度是就绪列表。每次在使用 poll 或者 select 之后,应用程序都需要扫描整个感兴趣的事件集合,估算出真正活动的事件,这个列表如果增长到 10K 以上,每次扫描的时间耗掉也是惊人的。事实上,很多情况下扫描完一轮,可能发现只有几个真正活动的事件。而 epoll 则不然,epoll 直接返回的程序就是事件活动的列表,应用大量的扫描时间。
另外,epoll还提供了更高级的能力——边缘触发。第23讲通过一个细心的例子,讲解了边缘触发和条件触发的区别。
这里再举一个例子说明一下。
如果某个设备有 100 个字节可以读取,边缘触发和条件触发都会产生读就绪通知事件,如果应用程序只读取了 50 个字节,触发边缘陷入就会等待;而条件触发由于还有 50 个字节没有读取完成,不断地产生读就绪通知事件。
在边缘触发下,如果有一些导入导出可以写入,会无限次返回写入就绪通知事件,在这种情况下,如果应用程序没有准备好,不需要导出数据,就需要导入导出上的写入就绪通知事件,否则CPU就直接跪了。
我们简单地总结一下,边缘触发只会产生一次活动事件,性能和效率更高。但是,程序处理起来要更加小心。
总结
本讲修改我们将程序框架切换到了 epoll 的版本,和 poll 版本相比,只是底层的框架做了改变,上层应用程序不用做任何事情,这也是程序框架强大的地方。和 poll 相比,epoll 从事件集合和就绪列表两个方面增强了程序性能,是 Linux 下高性能网络程序的首选。
从这一讲开始,我们进入实战篇,开启一个高性能 HTTP 服务器的编写之旅。
在开始编写高性能 HTTP 服务器之前,我们先要构建一个支持 TCP 的高性能网络编程框架,完成这个 TCP 高性能网络框架之后,再增加 HTTP 特性的支持就比较容易了,这样就可以很快开发出一个高性能的 HTTP 服务器程序。
设计需求
在第三个模块性能篇中,我们已经使用这个网络编程框架完成了多个应用程序的开发,这也等于对这个网络编程框架提出了编程接口方面的需求,综合之前的使用经验,这个 TCP 高性能网络框架需要满足的需求有以下三点。
第一,采用 reactor 模型,可以灵活使用 poll/epoll 作为事件分发实现。
第二,必须支持多线程,从而可以支持单线程单 reactor 模式,也可以支持多线程主 - 从 reactor 模式。可以将套接字上的 I/O 事件分离到多个线程上。
第三,封装读写操作到 Buffer 对象中。
按照这三个需求,正好可以把整体设计思路分成三块来讲解,分别包括反应堆模式设计、I/O 模型和多线程模型设计、数据读写封装和 buffer。今天我们主要讲一下主要的设计思路和数据结构,以及反应堆模式设计。
主要设计思路
反应堆模式设计
反应堆模式,按照性能篇的讲解,主要是设计一个基于事件分发和回调的反应堆框架。这个框架里面的主要对象包括:
-
event_loop
你可以把 event_loop 这个对象理解成和一个线程绑定的无限事件循环,你会在各种语言里看到 event_loop 这个抽象。这是什么意思呢?简单来说,它就是一个无限循环着的事件分发器,一旦有事件发生,它就会回调预先定义好的回调函数,完成事件的处理。
具体来说,event_loop 使用 poll 或者 epoll 方法将一个线程阻塞,等待各种 I/O 事件的发生。
-
channel
对各种注册到 event_loop 上的对象,我们抽象成 channel 来表示,例如注册到 event_loop 上的监听事件,注册到 event_loop 上的套接字读写事件等。在各种语言的 API 里,你都会看到 channel 这个对象,大体上它们表达的意思跟我们这里的设计思路是比较一致的。
-
acceptor
acceptor 对象表示的是服务器端监听器,acceptor 对象最终会作为一个 channel 对象,注册到 event_loop 上,以便进行连接完成的事件分发和检测。
-
event_dispatcher
event_dispatcher 是对事件分发机制的一种抽象,也就是说,可以实现一个基于 poll 的 poll_dispatcher,也可以实现一个基于 epoll 的 epoll_dispatcher。在这里,我们统一设计一个 event_dispatcher 结构体,来抽象这些行为。
-
channel_map
channel_map 保存了描述字到 channel 的映射,这样就可以在事件发生时,根据事件类型对应的套接字快速找到 chanel 对象里的事件处理函数。
I/O 模型和多线程模型设计
I/O 线程和多线程模型,主要解决 event_loop 的线程运行问题,以及事件分发和回调的线程执行问题。
-
thread_pool
thread_pool 维护了一个 sub-reactor 的线程列表,它可以提供给主 reactor 线程使用,每次当有新的连接建立时,可以从 thread_pool 里获取一个线程,以便用它来完成对新连接套接字的 read/write 事件注册,将 I/O 线程和主 reactor 线程分离。
-
event_loop_thread
event_loop_thread 是 reactor 的线程实现,连接套接字的 read/write 事件检测都是在这个线程里完成的。
Buffer 和数据读写
-
buffer
buffer 对象屏蔽了对套接字进行的写和读的操作,如果没有 buffer 对象,连接套接字的 read/write 事件都需要和字节流直接打交道,这显然是不友好的。所以,我们也提供了一个基本的 buffer 对象,用来表示从连接套接字收取的数据,以及应用程序即将需要发送出去的数据。
-
tcp_connection
tcp_connection 这个对象描述的是已建立的 TCP 连接。它的属性包括接收缓冲区、发送缓冲区、channel 对象等。这些都是一个 TCP 连接的天然属性。
tcp_connection 是大部分应用程序和我们的高性能框架直接打交道的数据结构。我们不想把最下层的 channel 对象暴露给应用程序,因为抽象的 channel 对象不仅仅可以表示 tcp_connection,前面提到的监听套接字也是一个 channel 对象,后面提到的唤醒 socketpair 也是一个 channel 对象。所以,我们设计了 tcp_connection 这个对象,希望可以提供给用户比较清晰的编程入口。
反应堆模式设计
概述
下面,我们详细讲解一下以 event_loop 为核心的反应堆模式设计。我在文稿里放置了一张 event_loop 的运行详图,你可以对照这张图来理解。

当 event_loop_run 完成之后,线程进入循环,首先执行 dispatch 事件分发,一旦有事件发生,就会调用 channel_event_activate 函数,在这个函数中完成事件回调函数 eventReadcallback 和 eventWritecallback 的调用,最后再进行 event_loop_handle_pending_channel,用来修改当前监听的事件列表,完成这个部分之后,又进入了事件分发循环。
event_loop 分析
说 event_loop 是整个反应堆模式设计的核心,一点也不为过。先看一下 event_loop 的数据结构。
在这个数据结构中,最重要的莫过于 event_dispatcher 对象了。你可以简单地把 event_dispatcher 理解为 poll 或者 epoll,它可以让我们的线程挂起,等待事件的发生。
这里有一个小技巧,就是 event_dispatcher_data,它被定义为一个 void * 类型,可以按照我们的需求,任意放置一个我们需要的对象指针。这样,针对不同的实现,例如 poll 或者 epoll,都可以根据需求,放置不同的数据对象。
event_loop 中还保留了几个跟多线程有关的对象,如 owner_thread_id 是保留了每个 event loop 的线程 ID,mutex 和 con 是用来进行线程同步的。
socketPair 是父线程用来通知子线程有新的事件需要处理。pending_head 和 pending_tail 是保留在子线程内的需要处理的新的事件。
|
struct event_loop {
|
|
int quit;
|
|
const struct event_dispatcher *eventDispatcher;
|
|
|
|
/** 对应的 event_dispatcher 的数据. */
|
|
void *event_dispatcher_data;
|
|
struct channel_map *channelMap;
|
|
|
|
int is_handle_pending;
|
|
struct channel_element *pending_head;
|
|
struct channel_element *pending_tail;
|
|
|
|
pthread_t owner_thread_id;
|
|
pthread_mutex_t mutex;
|
|
pthread_cond_t cond;
|
|
int socketPair[2];
|
|
char *thread_name;
|
|
};
|
下面我们看一下 event_loop 最主要的方法 event_loop_run 方法,前面提到过,event_loop 就是一个无限 while 循环,不断地在分发事件。
|
/**
|
|
*
|
|
* 1. 参数验证
|
|
* 2. 调用 dispatcher 来进行事件分发, 分发完回调事件处理函数
|
|
*/
|
|
int event_loop_run(struct event_loop *eventLoop) {
|
|
assert(eventLoop != NULL);
|
|
|
|
struct event_dispatcher *dispatcher = eventLoop->eventDispatcher;
|
|
|
|
if (eventLoop->owner_thread_id != pthread_self()) {
|
|
exit(1);
|
|
}
|
|
|
|
yolanda_msgx("event loop run, %s", eventLoop->thread_name);
|
|
struct timeval timeval;
|
|
timeval.tv_sec = 1;
|
|
|
|
while (!eventLoop->quit) {
|
|
//block here to wait I/O event, and get active channels
|
|
dispatcher->dispatch(eventLoop, &timeval);
|
|
|
|
//handle the pending channel
|
|
event_loop_handle_pending_channel(eventLoop);
|
|
}
|
|
|
|
yolanda_msgx("event loop end, %s", eventLoop->thread_name);
|
|
return 0;
|
|
}
|
代码很明显地反映了这一点,这里我们在 event_loop 不退出的情况下,一直在循环,循环体中调用了 dispatcher 对象的 dispatch 方法来等待事件的发生。
event_dispacher 分析
为了实现不同的事件分发机制,这里把 poll、epoll 等抽象成了一个 event_dispatcher 结构。event_dispatcher 的具体实现有 poll_dispatcher 和 epoll_dispatcher 两种,实现的方法和性能篇21讲和22 讲类似,这里就不再赘述,你如果有兴趣的话,可以直接研读代码。
|
/** 抽象的 event_dispatcher 结构体,对应的实现如 select,poll,epoll 等 I/O 复用. */
|
|
struct event_dispatcher {
|
|
/** 对应实现 */
|
|
const char *name;
|
|
|
|
/** 初始化函数 */
|
|
void *(*init)(struct event_loop * eventLoop);
|
|
|
|
/** 通知 dispatcher 新增一个 channel 事件 */
|
|
int (*add)(struct event_loop * eventLoop, struct channel * channel);
|
|
|
|
/** 通知 dispatcher 删除一个 channel 事件 */
|
|
int (*del)(struct event_loop * eventLoop, struct channel * channel);
|
|
|
|
/** 通知 dispatcher 更新 channel 对应的事件 */
|
|
int (*update)(struct event_loop * eventLoop, struct channel * channel);
|
|
|
|
/** 实现事件分发,然后调用 event_loop 的 event_activate 方法执行 callback*/
|
|
int (*dispatch)(struct event_loop * eventLoop, struct timeval *);
|
|
|
|
/** 清除数据 */
|
|
void (*clear)(struct event_loop * eventLoop);
|
|
};
|
channel 对象分析
channel 对象是用来和 event_dispather 进行交互的最主要的结构体,它抽象了事件分发。一个 channel 对应一个描述字,描述字上可以有 READ 可读事件,也可以有 WRITE 可写事件。channel 对象绑定了事件处理函数 event_read_callback 和 event_write_callback。
|
typedef int (*event_read_callback)(void *data);
|
|
|
|
typedef int (*event_write_callback)(void *data);
|
|
|
|
struct channel {
|
|
int fd;
|
|
int events; // 表示 event 类型
|
|
|
|
event_read_callback eventReadCallback;
|
|
event_write_callback eventWriteCallback;
|
|
void *data; //callback data, 可能是 event_loop,也可能是 tcp_server 或者 tcp_connection
|
|
};
|
channel_map 对象分析
event_dispatcher 在获得活动事件列表之后,需要通过文件描述字找到对应的 channel,从而回调 channel 上的事件处理函数 event_read_callback 和 event_write_callback,为此,设计了 channel_map 对象。
|
/**
|
|
* channel 映射表, key 为对应的 socket 描述字
|
|
*/
|
|
struct channel_map {
|
|
void **entries;
|
|
|
|
/* The number of entries available in entries */
|
|
int nentries;
|
|
};
|
channel_map 对象是一个数组,数组的下标即为描述字,数组的元素为 channel 对象的地址。
比如描述字 3 对应的 channel,就可以这样直接得到。
|
struct chanenl * channel = map->entries[3];
|
这样,当 event_dispatcher 需要回调 chanel 上的读、写函数时,调用 channel_event_activate 就可以,下面是 channel_event_activate 的实现,在找到了对应的 channel 对象之后,根据事件类型,回调了读函数或者写函数。注意,这里使用了 EVENT_READ 和 EVENT_WRITE 来抽象了 poll 和 epoll 的所有读写事件类型。
|
int channel_event_activate(struct event_loop *eventLoop, int fd, int revents) {
|
|
struct channel_map *map = eventLoop->channelMap;
|
|
yolanda_msgx("activate channel fd == %d, revents=%d, %s", fd, revents, eventLoop->thread_name);
|
|
|
|
if (fd < 0)
|
|
return 0;
|
|
|
|
if (fd >= map->nentries)return (-1);
|
|
|
|
struct channel *channel = map->entries[fd];
|
|
assert(fd == channel->fd);
|
|
|
|
if (revents & (EVENT_READ)) {
|
|
if (channel->eventReadCallback) channel->eventReadCallback(channel->data);
|
|
}
|
|
if (revents & (EVENT_WRITE)) {
|
|
if (channel->eventWriteCallback) channel->eventWriteCallback(channel->data);
|
|
}
|
|
|
|
return 0;
|
|
}
|
增加、删除、修改 channel event
那么如何增加新的 channel event 事件呢?这几个函数是用来增加、删除和修改 channel event 事件的。
|
int event_loop_add_channel_event(struct event_loop *eventLoop, int fd, struct channel *channel1);
|
|
|
|
int event_loop_remove_channel_event(struct event_loop *eventLoop, int fd, struct channel *channel1);
|
|
|
|
int event_loop_update_channel_event(struct event_loop *eventLoop, int fd, struct channel *channel1);
|
前面三个函数提供了入口能力,而真正的实现则落在这三个函数上:
|
int event_loop_handle_pending_add(struct event_loop *eventLoop, int fd, struct channel *channel);
|
|
|
|
int event_loop_handle_pending_remove(struct event_loop *eventLoop, int fd, struct channel *channel);
|
|
|
|
int event_loop_handle_pending_update(struct event_loop *eventLoop, int fd, struct channel *channel);
|
我们看一下其中的一个实现,event_loop_handle_pendign_add 在当前 event_loop 的 channel_map 里增加一个新的 key-value 对,key 是文件描述字,value 是 channel 对象的地址。之后调用 event_dispatcher 对象的 add 方法增加 channel event 事件。注意这个方法总在当前的 I/O 线程中执行。
|
// in the i/o thread
|
|
int event_loop_handle_pending_add(struct event_loop *eventLoop, int fd, struct channel *channel) {
|
|
yolanda_msgx("add channel fd == %d, %s", fd, eventLoop->thread_name);
|
|
struct channel_map *map = eventLoop->channelMap;
|
|
|
|
if (fd < 0)
|
|
return 0;
|
|
|
|
if (fd >= map->nentries) {
|
|
if (map_make_space(map, fd, sizeof(struct channel *)) == -1)
|
|
return (-1);
|
|
}
|
|
|
|
// 第一次创建,增加
|
|
if ((map)->entries[fd] == NULL) {
|
|
map->entries[fd] = calloc(1, sizeof(struct channel *));
|
|
map->entries[fd] = channel;
|
|
//add channel
|
|
struct event_dispatcher *eventDispatcher = eventLoop->eventDispatcher;
|
|
eventDispatcher->add(eventLoop, channel);
|
|
return 1;
|
|
}
|
|
|
|
return 0;
|
|
}
|
总结
在这一讲里,我们介绍了高性能网络编程框架的主要设计思路和基本数据结构,以及反应堆设计相关的具体做法。在接下来的章节中,我们将继续编写高性能网络编程框架的线程模型以及读写 Buffer 部分。
多线程设计的几个考虑


主线程等待多个 sub-reactor 子线程初始化完
增加已连接套接字事件到 sub-reactor 线程中
总结
buffer 对象



套接字接收数据处理
套接字发送数据处理
HTTP 协议实现

完整的 HTTP 服务器例子

总结
学习源码

浙公网安备 33010602011771号