This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,62 @@
<audio id="audio" title="开篇词 | 学好网络编程,需要掌握哪些核心问题?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/21/3d/21d21a7ffad2e0be1d5c5fc510f2bc3d.mp3"></audio>
你好,我是盛延敏,欢迎你的加入。在接下来的几个月时间里,我会和你一起深入学习网络编程。网络编程是一个高度重视实战的内容模块,工作这么多年,我一直都在近距离与它打交道。
我博士毕业于中科大毕业之后就加入了IBM在IBM从事WebSphere应用服务器、PaaS平台Bluemix等系统的开发工作后来又在大众点评担任云平台首席架构师推动了以Docker为核心的私有云的建设和落地。现在我在蚂蚁金服专注于云计算的架构和开发等方面的事情。正是这些工作经历让我对网络编程有了更为深入的理解。
2000年初我就开始使用网络编程框架ACE、CORBA等技术从事电信网管系统的开发后来又开始接触ICE写分布式控制系统再之后转向Java使用JDK从事Web Service和应用服务器的网络开发和研究使用Netty从事Java高性能网络编程的开发。作为一个网络程序设计和开发的老兵我目前继续在云计算领域深度耕耘更是少不了和各种网络设计和开发打交道。
得益于这么多年的深耕,在工作过程中,我也接触了不少同学,面试过很多候选人,令我惊讶的是,很多工作很久的同学对网络协议和网络编程一知半解,仅仅停留在一个很粗浅的水平。
事实上,无论是在面试中,还是在工作中,掌握网络编程领域的知识都是一个非常重要的基本功。这在互联网蓬勃发展的今天,更是如此。
如果我问你一些关于网络编程方面的问题,你会怎样回答呢?
- 大家经常说的四层、七层,分别指的是什么?
- TCP三次握手是什么TIME_WAIT是怎么发生的CLOSE_WAIT又是什么状态
- Linux下的epoll解决的是什么问题如何使用epoll写出高性能的网络程序
- 什么是网络事件驱动模型Reactor模式又是什么
这些问题看似简单,但想做到完全理解可并不容易。很多人可能停留在“是这样”的状态,对于“为什么”缺乏深入和了解。
我在学网络编程的时候,也面临着这种窘境。我发现很多情况下,我们都希望尽可能详尽地学习网络编程,面面俱到,但奈何头绪太多,对于初学者来说很容易深陷其中,钻牛角尖,也就难以去理清脉络了。
这样导致的后果就是过分关注知识点本身,片面地斩断了它们与实际工作的联系。比如流量控制和拥塞控制这一部分的内容。我记得我在学这部分知识的时候,纯粹是把这些当作考试的知识点来学习,很难将书本的知识和实际经验,尤其是和代码结合起来进行理解。为什么会有这些算法,它们究竟解决了哪些问题?这些问题搞不懂,看似无伤大雅,其实已经或多或少地和实际工作产生了断层。
流量控制和拥塞控制只是网络编程一小部分的内容进程、线程、多路复用、异步I/O这些概念一摆出来又会让人一头雾水。从哪里学怎么学
很多人在理论部分折了戟,干脆跑向了另一个极端,转而去学习框架,快速上手。事实上,理论是基石,框架则是站在一个更为抽象的角度来看待网络编程问题。框架的产生或是为了实现跨平台支持,例如 JDK或是为了屏蔽网络编程的细节让开发更为方便例如 libevent。
没有理论为底,框架也只是空中楼阁。直接学习框架反而会更加摸不着头脑,对网络编程也很难有实打实的收获。
那么你可能会问,理论学不懂,框架又不行,到底应该怎么学?
事实上,我认为学习高性能网络编程,掌握两个核心要点就可以了:**第一就是理解网络协议并在这个基础上和操作系统内核配合感知各种网络I/O事件第二就是学会使用线程处理并发**。抓住这两个核心问题,也就抓住了高性能网络编程的“七寸”。我会从实践出发,单刀直入地展开,从问题的角度对这些看似枯燥的知识点进行阐述。
我会跟你一起在专栏里研究某个理论或者算法,看看它提出来的目的到底是什么,又解决了哪些问题。我还会和代码进行关联,和实验进行关联,循序渐进地引出这个理论或者算法的实际意义。
我会从最简单的网络套接字开始一步步带你写出健硕的高性能网络程序。在这个过程里我会尽我所能将自己在这个领域中多年的体会和专栏内容融为一体帮助你理解协议、API和代码。
在专栏里我们会重点展开对Linux网络编程的学习。原因也很简单目前Linux系统已成为互联网数据中心的标配再加上基于Linux的移动开发平台Android的迅速崛起Linux的重要性愈发明显。对有志于学习网络编程的你来说这是一个非常明智的选择。
我在下面放了一张专栏的目录,专栏将分别从基础篇、提升篇、性能篇和实战篇展开。
<img src="https://static001.geekbang.org/resource/image/d9/b4/d9868ff93c308befe7bb3f06cfc828b4.jpg" alt="">
在我看来,要学好网络编程,需要达到以下三个层次。
**第一个层次充分理解TCP/IP网络模型和协议**。在这方面,仅仅做到理论上的理解是远远不够的。在**基础篇**中我们会梳理TCP/IP模型和网络函数接口之间的联系并通过实例展开对套接字套接字缓冲区拥塞控制数据包和数据流本地套接字UNIX域套接字等的讨论给你一个全面而具体的知识体系。
**第二个层次,结合对协议的理解,增强对各种异常情况的优雅处理能力**。比如对TCP数据流的处理半关闭的连接TCP连接有效性的侦测处理各种异常情况等这些问题决定了程序的健壮性。有关这一部分的内容我们将在**提升篇**详细展开。
**第三个层次,写出可以支持大规模高并发的网络处理程序**。在这个阶段我将带你一起深入研究C10K问题引入进程、线程、多路复用、非阻塞、异步、事件驱动等现代高性能网络编程所需要的技术。我们将在**性能篇**深入讨论这些技术,并在**实战篇**结合实例,一步步教你写出高性能的网络程序。
“不闻不若闻之,闻之不若见之,见之不若知之,知之不若行之。”几千年前荀子的这段话,告诉了我们学习计算机编程技术的最佳方法,我想用在这里再合适不过了。
所以,无论是哪个层次,都需要你和我一起坚持下去。你可以对专栏里的代码进行修改和调试,从而增强你对网络编程的理解。
最后,请你留言说一说自己的情况。你在什么岗位?工作几年啦?在日常工作中是怎么使用网络编程的?开张圣听,期待跟你碰撞出更多精彩想法。感谢你对我的信任,我一定会竭尽所能,助你成功。
让我们一起进步。
<img src="https://static001.geekbang.org/resource/image/bf/25/bfc96ae0d8f839919b9d9866cfb8b025.jpg" alt="">

View File

@@ -0,0 +1,129 @@
<audio id="audio" title="期中大作业丨动手编写一个自己的程序吧!" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e7/8d/e77de7324232befc46d3f9f6c6cc3f8d.mp3"></audio>
你好,我们之前已经学习了网络编程的基础篇和提高篇。经过近两个月的学习,不知道你对这些内容的掌握程度如何呢?
我之前说过网络编程是一个既重视理论又重视实战的内容模块。一味地消化理论并不足以让你掌握网络编程只有自己亲自动手写代码编写程序才能对TCP、UDP、套接字这些内容有更深切的体会才能切实感受到它们是如何帮助我们的程序进行互联互通的。
网络编程就像一个魔法棒,我们之前已经学习了一些“咒语”,但上手操纵才能真实地施展魔法。所以我在专栏中安排了一个期中作业,借由这个作业让你上手编写代码,相信你在这个过程中也会更有成就感。
我在这里再提供一些“咒语”提示,方便你回顾之前的内容,以便在解题的时候更加胸有成竹。
客户端程序可以以[第11篇文章](https://time.geekbang.org/column/article/126126)的程序例子为原型这里主要考察使用select多路复用一方面从标准输入接收字节流另一方面通过套接字读写以及使用shutdown关闭半连接的能力。
服务器端程序则考察套接字读写的能力,以及对端连接关闭情况下的异常处理等能力。
题目不难,相信你可以做好。
## 题干
请你分别写一个客户端程序和服务器程序,客户端程序连接上服务器之后,通过敲命令和服务器进行交互,支持的交互命令包括:
- pwd显示服务器应用程序启动时的当前路径。
- cd改变服务器应用程序的当前路径。
- ls显示服务器应用程序当前路径下的文件列表。
- quit客户端进程退出但是服务器端不能退出第二个客户可以再次连接上服务器端。
## 客户端程序要求
1. 可以指定待连接的服务器端IP地址和端口。
1. 在输入一个命令之后,回车结束,之后等待服务器端将执行结果返回,客户端程序需要将结果显示在屏幕上。
1. 样例输出如下所示。
```
第一次连接服务器
$./telnet-client 127.0.0.1 43211
pwd
/home/vagrant/shared/Code/network/yolanda/build/bin
cd ..
pwd
/home/vagrant/shared/Code/network/yolanda/build
cd ..
pwd
/home/vagrant/shared/Code/network/yolanda
ls
build
chap-11
chap-12
chap-13
chap-14
chap-15
chap-16
chap-17
chap-18
chap-20
chap-21
chap-22
chap-23
chap-25
chap-26
chap-27
chap-28
chap-4
chap-5
chap-6
chap-7
clean.sh
cmake-build-debug
CMakeLists.txt
lib
mid-homework
README.md
cd -
pwd
/home/vagrant/shared/Code/network/yolanda
cd /home
pwd
/home
ls
ubuntu
vagrant
quit
//再次连接服务器
$./telnet-client 127.0.0.1 43211
pwd
/home/vagrant/shared/Code/network/yolanda/build
ls
bin
chap-11
chap-12
chap-13
chap-15
chap-16
chap-17
chap-18
chap-20
chap-21
chap-22
chap-23
chap-25
chap-26
chap-28
chap-4
chap-5
chap-6
chap-7
CMakeCache.txt
CMakeFiles
cmake_install.cmake
lib
Makefile
mid-homework
quit
```
## 服务器程序要求
1. 暂时不需要考虑多个客户并发连接的情形,只考虑每次服务一个客户连接。
1. 要把命令执行的结果返回给已连接的客户端。
1. 服务器端不能因为客户端退出就直接退出。
你可以把自己编写的程序代码放到GitHub上并在评论里留下链接。我会认真查看这些代码并在周五给出自己的反馈意见以及题目分析。由于时间有限无法尽数查看后续我会以答疑或者加餐的形式再做补充。
期待你的成果!

View File

@@ -0,0 +1,227 @@
<audio id="audio" title="期中大作业丨题目以及解答剖析" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e3/93/e36747798ad19a50a3d65a53165a8693.mp3"></audio>
你好今天是期中大作业讲解课。诚如一位同学所言这次的大作业不是在考察网络编程的细节而是在考如何使用系统API完成cd、pwd、ls等功能。不过呢网络编程的框架总归还是要掌握的。
我研读了大部分同学的代码,基本上是做得不错的,美中不足的是能动手完成代码编写和调试的同学偏少。我还是秉持一贯的看法,计算机程序设计是一门实战性很强的学科,如果只是单纯地听讲解,没有自己动手这一环,对知识的掌握总归还是差那么点意思。
代码我已经push到[这里](https://github.com/froghui/yolanda/tree/master/mid-homework),你可以点进链接看一下。
## 客户端程序
废话少说,我贴下我的客户端程序:
```
#include &quot;lib/common.h&quot;
#define MAXLINE 1024
int main(int argc, char **argv) {
if (argc != 3) {
error(1, 0, &quot;usage: tcp_client &lt;IPaddress&gt; &lt;port&gt;&quot;);
}
int port = atoi(argv[2]);
int socket_fd = tcp_client(argv[1], port);
char recv_line[MAXLINE], send_line[MAXLINE];
int n;
fd_set readmask;
fd_set allreads;
FD_ZERO(&amp;allreads);
FD_SET(0, &amp;allreads);
FD_SET(socket_fd, &amp;allreads);
for (;;) {
readmask = allreads;
int rc = select(socket_fd + 1, &amp;readmask, NULL, NULL, NULL);
if (rc &lt;= 0) {
error(1, errno, &quot;select failed&quot;);
}
if (FD_ISSET(socket_fd, &amp;readmask)) {
n = read(socket_fd, recv_line, MAXLINE);
if (n &lt; 0) {
error(1, errno, &quot;read error&quot;);
} else if (n == 0) {
printf(&quot;server closed \n&quot;);
break;
}
recv_line[n] = 0;
fputs(recv_line, stdout);
fputs(&quot;\n&quot;, stdout);
}
if (FD_ISSET(STDIN_FILENO, &amp;readmask)) {
if (fgets(send_line, MAXLINE, stdin) != NULL) {
int i = strlen(send_line);
if (send_line[i - 1] == '\n') {
send_line[i - 1] = 0;
}
if (strncmp(send_line, &quot;quit&quot;, strlen(send_line)) == 0) {
if (shutdown(socket_fd, 1)) {
error(1, errno, &quot;shutdown failed&quot;);
}
}
size_t rt = write(socket_fd, send_line, strlen(send_line));
if (rt &lt; 0) {
error(1, errno, &quot;write failed &quot;);
}
}
}
}
exit(0);
}
```
客户端的代码主要考虑的是使用select同时处理标准输入和套接字我看到有同学使用fgets来循环等待用户输入然后再把输入的命令通过套接字发送出去当然也是可以正常工作的只不过不能及时响应来自服务端的命令结果所以我还是推荐使用select来同时处理标准输入和套接字。
这里select如果发现标准输入有事件读出标准输入的字符就会通过调用write方法发送出去。如果发现输入的是quit则调用shutdown方法关闭连接的一端。
如果select发现套接字流有可读事件则从套接字中读出数据并把数据打印到标准输出上如果读到了EOF表示该客户端需要退出直接退出循环通过调用exit来完成进程的退出。
## 服务器端程序
下面是我写的服务器端程序:
```
#include &quot;lib/common.h&quot;
static int count;
static void sig_int(int signo) {
printf(&quot;\nreceived %d datagrams\n&quot;, count);
exit(0);
}
char *run_cmd(char *cmd) {
char *data = malloc(16384);
bzero(data, sizeof(data));
FILE *fdp;
const int max_buffer = 256;
char buffer[max_buffer];
fdp = popen(cmd, &quot;r&quot;);
char *data_index = data;
if (fdp) {
while (!feof(fdp)) {
if (fgets(buffer, max_buffer, fdp) != NULL) {
int len = strlen(buffer);
memcpy(data_index, buffer, len);
data_index += len;
}
}
pclose(fdp);
}
return data;
}
int main(int argc, char **argv) {
int listenfd;
listenfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server_addr;
bzero(&amp;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, &amp;on, sizeof(on));
int rt1 = bind(listenfd, (struct sockaddr *) &amp;server_addr, sizeof(server_addr));
if (rt1 &lt; 0) {
error(1, errno, &quot;bind failed &quot;);
}
int rt2 = listen(listenfd, LISTENQ);
if (rt2 &lt; 0) {
error(1, errno, &quot;listen failed &quot;);
}
signal(SIGPIPE, SIG_IGN);
int connfd;
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
char buf[256];
count = 0;
while (1) {
if ((connfd = accept(listenfd, (struct sockaddr *) &amp;client_addr, &amp;client_len)) &lt; 0) {
error(1, errno, &quot;bind failed &quot;);
}
while (1) {
bzero(buf, sizeof(buf));
int n = read(connfd, buf, sizeof(buf));
if (n &lt; 0) {
error(1, errno, &quot;error read message&quot;);
} else if (n == 0) {
printf(&quot;client closed \n&quot;);
close(connfd);
break;
}
count++;
buf[n] = 0;
if (strncmp(buf, &quot;ls&quot;, n) == 0) {
char *result = run_cmd(&quot;ls&quot;);
if (send(connfd, result, strlen(result), 0) &lt; 0)
return 1;
} else if (strncmp(buf, &quot;pwd&quot;, n) == 0) {
char buf[256];
char *result = getcwd(buf, 256);
if (send(connfd, result, strlen(result), 0) &lt; 0){
return 1;
}
free(result);
} else if (strncmp(buf, &quot;cd &quot;, 3) == 0) {
char target[256];
bzero(target, sizeof(target));
memcpy(target, buf + 3, strlen(buf) - 3);
if (chdir(target) == -1) {
printf(&quot;change dir failed, %s\n&quot;, target);
}
} else {
char *error = &quot;error: unknown input type&quot;;
if (send(connfd, error, strlen(error), 0) &lt; 0)
return 1;
}
}
}
exit(0);
}
```
服务器端程序需要两层循环,第一层循环控制多个客户端连接,当然咱们这里没有考虑使用并发,这在第三个模块中会讲到。严格来说,现在的服务器端程序每次只能服务一个客户连接。
第二层循环控制和单个连接的数据交互,因为我们不止完成一次命令交互的过程,所以这一层循环也是必须的。
大部分同学都完成了这个两层循环的设计,我觉得非常棒。
在第一层循环里通过accept完成了连接的建立获得连接套接字。
在第二层循环里先通过调用read函数从套接字获取字节流。我这里处理的方式是反复使用了buf缓冲每次使用之前记得都要调用bzero完成初始化以便重复利用。
如果读取数据为0则说明客户端尝试关闭连接这种情况下需要跳出第二层循环进入accept阻塞调用等待新的客户连接到来。我看到有同学使用了goto来完成跳转其实使用break跳出就可以了也有同学忘记跳转了这里需要再仔细看一下。
在读出客户端的命令之后就进入处理环节。通过字符串比较命令进入不同的处理分支。C语言的strcmp或者strncmp可以帮助我们进行字符串比较这个比较类似于Java语言的String equalsIgnoreCase方法。当然如果命令的格式有错需要我们把错误信息通过套接字传给客户端。
对于“pwd”命令我是通过调用getcwd来完成的getcwd是一个C语言的API可以获得当前的路径。
对于“cd”命令我是通过调用chdir来完成的cd是一个C语言的API可以将当前目录切换到指定的路径。有的同学在这里还判断支持了“cd ~”回到了当前用户的HOME路径这个非常棒我就没有考虑这种情况了。
对于“ls”命令我看到有同学是调用了scandir方法获得当前路径下的所有文件列表再根据每个文件类型进行了格式化的输出。这个方法非常棒是一个标准实现。我这里呢为了显得稍微不一样通过了popen的方法执行了ls的bash命令把bash命令的结果通过文件字节流的方式读出再将该字节流通过套接字传给客户端。我看到有的同学在自己的程序里也是这么做的。
这次的期中大作业,主要考察了客户端-服务器编程的基础知识。
客户端程序考察使用select多路复用一方面从标准输入接收字节流另一方面通过套接字读写以及使用shutdown关闭半连接的能力。
服务器端程序则考察套接字读写的能力,以及对端连接关闭情况下的异常处理等能力。
不过服务器端程序目前只能一次服务一个客户端连接不具备并发服务的能力。如何编写一个具备高并发服务能力的服务器端程序将是我们接下来课程的重点。我们将会重点讲述基于I/O多路复用的事件驱动模型并以此为基础设计一个高并发网络编程框架通过这个框架实现一个HTTP服务器。挑战和难度越来越高你准备好了吗?

View File

@@ -0,0 +1,8 @@
你好,我是盛延敏。
《网络编程实战》这个专栏已经完结很久了,在完结的这段时间里,我依然能收到很多评论。在这些评论中,有认真回答课后思考题的,也有积极提问的。这些评论极大地丰富了专栏的内容,感谢你一直以来的认真学习和支持。
为了让你更好地检测自己的学习成果我特意做了一套期末测试题。题目共有20道满分为100分快来检测一下吧
[<img src="https://static001.geekbang.org/resource/image/28/a4/28d1be62669b4f3cc01c36466bf811a4.png" alt="">](http://time.geekbang.org/quiz/intro?act_id=149&amp;exam_id=331)

View File

@@ -0,0 +1,181 @@
<audio id="audio" title="01 | 追古溯源TCP/IP和Linux是如何改变世界的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ac/c8/acd295377e01afc19716ab214423ebc8.mp3"></audio>
你好,我是盛延敏。今天是网络编程课程的第一课,我想你一定满怀热情,期望快速进入到技术细节里,了解那些你不熟知的编程技能。而今天我却想和你讲讲历史,虽然这些事情看着不是“干货”,但它可以帮助你理解网络编程中各种技术的来龙去脉。
你我都是程序员,说句实在话,我们正处于一个属于我们的时代里,我们也正在第一线享受着这个时代的红利。在我看来,人类历史上还从来没有一项技术可以像互联网一样深刻地影响人们生活的方方面面。
而具体到互联网技术里有两件事最为重要一个是TCP/IP协议它是万物互联的事实标准另一个是Linux操作系统它是推动互联网技术走向繁荣的基石。
今天我就带你穿越时间的走廊看一看TCP/IP事实标准和Linux操作系统是如何一步一步发展到今天的。
## TCP发展历史
一般来说我们认为互联网起源于阿帕网ARPANET
最早的阿帕网还是非常简陋的网络控制协议Network Control Protocol缩写NCP是阿帕网中连接不同计算机的通信协议。
在构建阿帕网ARPANET之后其他数据传输技术的研究又被摆上案头。NCP诞生两年后NCP的开发者温特·瑟夫Vinton Cerf和罗伯特·卡恩Robert E. Kahn一起开发了一个阿帕网的下一代协议并在1974年发表了以分组、序列化、流量控制、超时和容错等为核心的一种新型的网络互联协议一举奠定了TCP/IP协议的基础。
### OSI &amp; TCP/IP
在这之后TCP/IP逐渐发展。咱们话分两头说一头是一个叫ISO的组织发现计算机设备的互联互通是一个值得研究的新领域于是这个组织出面和众多厂商游说“我们一起定义出一个网络互联互通的标准吧这样大家都遵守这个标准一起把这件事情做大大家就有钱赚了”。众多厂商觉得可以啊于是ISO组织就召集了一帮人认真研究起了网络互联这件事情还真的搞出来一个非常强悍的标准这就是OSI参考模型。这里我不详细介绍OSI参考模型了你可以阅读[罗剑锋老师的专栏](https://time.geekbang.org/column/article/99286),他讲得很好。
这个标准发布的时候已经是1984年了有点尴尬的是OSI搞得是很好大家也都很满意不过等它发布的时候ISO组织却惊讶地发现满世界都在用一个叫做TCP/IP协议栈的东西而且跟OSI标准半毛钱关系都没有。
这就涉及到了另一头——TCP/IP的发展。
事实上我在前面提到的那两位牛人卡恩和瑟夫一直都在不遗余力地推广TCP/IP。当然TCP/IP的成功也不是偶然的而是综合了几个因素后的结果
1. TCP/IP是免费或者是少量收费的这样就扩大了使用人群
1. TCP/IP搭上了UNIX这辆时代快车很快推出了基于套接字socket的实际编程接口
1. 这是最重要的一点TCP/IP来源于实际需求大家都在翘首盼望出一个统一标准可是在此之前实际的问题总要解决啊TCP/IP解决了实际问题并且在实际中不断完善。
回过来看OSI的七层模型定得过于复杂并且没有参考实现在一定程度上阻碍了普及。
不过OSI教科书般的层次模型对后世的影响很深远一般我们说的4层、7层也是遵从了OSI模型的定义分别指代传输层和应用层。
我们说TCP/IP的应用层对应了OSI的应用层、表示层和会话层TCP/IP的网络接口层对应了OSI的数据链路层和物理层。
<img src="https://static001.geekbang.org/resource/image/cb/b4/cb34e0e3b7769498ea703fe6231201b4.png" alt="">
## UNIX操作系统发展历史
前面我们提到了TCP/IP协议的成功离不开UNIX操作系统的发展。接下来我们就看下UNIX操作系统是如何诞生和演变的。
下面这张图摘自[维基百科](https://en.wikipedia.org/wiki/File:Unix_timeline.en.svg)它将UNIX操作系统几十年的发展历史表述得非常清楚。
<img src="https://static001.geekbang.org/resource/image/a6/0f/a68c3b9b267574ea2f309ed6a4e0de0f.png" alt=""><br>
UNIX的各种版本和变体都起源于在PDP-11系统上运行的UNIX分时系统第6版1976年和第7版1979年它们通常分别被称为V6和V7。这两个版本是在贝尔实验室以外首先得到广泛应用的UNIX系统。
这张图画得比较概括我们主要从这张图上看3个分支
- 图上标示的Research橘黄色部分是由AT&amp;T贝尔实验室不断开发的UNIX研究版本从此引出UNIX分时系统第8版、第9版终止于1990年的第10版10.5。这个版本可以说是操作系统界的少林派。天下武功皆出少林世上UNIX皆出自贝尔实验室。
- 图中最上面所标识的操作系统版本是加州大学伯克利分校BSD研究出的分支从此引出4.xBSD实现以及后面的各种BSD版本。这个可以看做是学院派。在历史上学院派有力地推动了UNIX的发展包括我们后面会谈到的socket套接字都是出自此派。
- 图中最下面的那一个部分是从AT&amp;T分支的商业派致力于从UNIX系统中谋取商业利润。从此引出了System III和System V被称为UNIX的商用版本还有各大公司的UNIX商业版。
下面这张图也是源自维基百科将UNIX的历史表达得更为详细。
<img src="https://static001.geekbang.org/resource/image/df/bb/df2b6d77a0a46e3d9b068f6d517a15bb.png" alt=""><br>
一个基本事实是网络编程套接字接口最早是在BSD 4.2引入的这个时间大概是1983年几经演变后成为了事实标准包括System III/V分支也吸收了这部分能力在上面这张大图上也可以看出来。
其实这张图也说明了一个很有意思的现象BSD分支、System III/System V分支、正统的UNIX分时系统分支都是互相借鉴的也可以说是互相“抄袭”吧。但如果这样发展下去互相不买对方的账导致上层的应用程序在不同的UNIX版本间不能很好地兼容这该怎么办这里先留一个疑问你也可以先想一想稍后我会给你解答。
下面我再介绍几个你耳熟能详的重要UNIX玩家。
### SVR 4
SVR4UNIX System V Release 4是AT&amp;T的UNIX系统实验室的一个商业产品。它基本上是一个操作系统的大杂烩这个操作系统之所以重要是因为它是System III/V分支各家商业化UNIX操作系统的“先祖”包括IBM的AIX、HP的HP-UX、SGI的IRIX、Sun后被Oracle收购的Solaris等等。
### Solaris
Solaris是由Sun Microsystems现为Oracle开发的UNIX系统版本它基于SVR4并且在商业上获得了不俗的成绩。2005年Sun Microsystems开源了Solaris操作系统的大部分源代码作为OpenSolaris开放源代码操作系统的一部分。相对于Linux这个开源操作系统的进展比较一般。
### BSD
BSDBerkeley Software Distribution我们上面已经介绍过了是由加州大学伯克利分校的计算机系统研究组CSRG研究开发和分发的。4.2BSD于1983年问世其中就包括了网络编程套接口相关的设计和实现4.3BSD则于1986年发布正是由于TCP/IP和BSD操作系统的完美拍档才有了TCP/IP逐渐成为事实标准的这一历史进程。
### macOS X
用mac笔记本的同学都有这样的感觉macOS提供的环境和Linux环境非常像很多代码可以在macOS上以接近线上Linux真实环境的方式运行。
有心的同学应该想过,这背后有一定的原因。
答案其实很简单macOS和Linux的血缘是相近的它们都是UNIX基础上发展起来的或者说它们各自就是一个类UNIX的系统。
macOS系统又被称为Darwin它已被验证过就是一个UNIX操作系统。如果打开Mac系统的socket.h头文件定义你会明显看到macOS系统和BSD千丝万缕的联系说明这就是从BSD系统中移植到macOS系统来的。
## Linux
我们把Linux操作系统单独拿出来讲是因为它实在太重要了全世界绝大部分数据中心操作系统都是跑在Linux上的就连手机操作系统Android也是一个被“裁剪”过的Linux操作系统。
Linux操作系统的发展有几个非常重要的因素这几个因素叠加在一起造就了如今Linux非凡的成就。我们一一来看。
### UNIX的出现和发展
第一个就是UNIX操作系统要知道Linux操作系统刚出世的时候 4.2/4.3 BSD都已经出现快10年了这样就为Linux系统的发展提供了一个方向而且Linux的开发语言是C语言C语言也是在UNIX开发过程中发明的一种语言。
### POSIX标准
UNIX操作系统虽然好但是它的源代码是不开源的。那么如何向UNIX学习呢这就要讲一下POSIX标准了POSIXPortable Operating System Interface for Computing Systems这个标准基于现有的UNIX实践和经验描述了操作系统的调用服务接口。有了这么一个标准Linux完全可以去实现并兼容它这从最早的Linux内核头文件的注释可见一斑。
这个头文件里定义了一堆POSIX宏并有一句注释“嗯也许只是一个玩笑不过我正在完成它。”
```
# ifndef _UNISTD_H
# define _UNISTD_H
/* ok, this may be a joke, but I'm working on it */
# define _POSIX_VERSION 198808L
# define _POSIX_CHOWN_RESTRICTED /* only root can do a chown (I think..) */
/* #define _POSIX_NO_TRUNC*/ /* pathname truncation (but see in kernel) */
# define _POSIX_VDISABLE '\0' /* character to disable things like ^C */
/*#define _POSIX_SAVED_IDS */ /* we'll get to this yet */
/*#define _POSIX_JOB_CONTROL */ /* we aren't there quite yet. Soon hopefully */
```
POSIX相当于给大厦画好了图纸给Linux的发展提供了非常好的指引。这也是为什么我们的程序在macOS和Linux可以兼容运行的原因因为大家用的都是一张图纸只不过制造商不同程序当然可以兼容运行了。
### Minix操作系统
刚才提到了UNIX操作系统不开源的问题那么有没有一开始就开源的UNIX操作系统呢这里就要提到Linux发展的第三个机遇Minix操作系统它在早期是Linux发展的重要指引。这个操作系统是由一个叫做安迪·塔能鲍姆Andy Tanenbaum的教授开发的他的本意是用来做UNIX教学的甚至有人说如果Minix操作系统也完全走社区开放的道路那么未必有现在的Linux操作系统。当然这些话咱们就权当作是马后炮了。Linux早期从Minix中借鉴了一些思路包括最早的文件系统等。
### GNU
Linux操作系统得以发展还有一个非常重要的因素那就是GNUGNUs NOT UNIX它的创始人就是鼎鼎大名的理查·斯托曼Richard Stallman。斯托曼的想法是设计一个完全自由的软件系统用户可以自由使用自由修改这些软件系统。
GNU为什么对Linux的发展如此重要呢事实上GNU之于Linux是要早很久的GNU在1984年就正式诞生了。最开始斯托曼是想开发一个类似UNIX的操作系统的。
>
<p>From CSvax:pur-ee:inuxc!ixn5c!ihnp4!houxm!mhuxi!eagle!mit-vax!mit-eddie!RMS@ MIT-OZ<br>
From: RMS% MIT-OZ@ mit-eddie<br>
Newsgroups: net.unix-wizards,net.usoft<br>
Subj ect: new UNIX implementation<br>
Date: Tue, 27-Sep-83 12:35:59 EST<br>
Organization: MIT AI Lab, Cambridge, MA<br>
Free Unix!<br>
Starting this Thanksgiving I am going to write a complete Unix-compatible software system called GNU (for Gnus Not Unix), and give it away free to everyone who can use it. Contributions of time,money, programs and equipment are greatly needed.<br>
To begin with, GNU will be a kernel plus all the utilities needed to write and run C programs: editor, shell, C compiler, linker, assembler, and a few other things. After this we will add a text formatter, a YACC, an Empire game, a spreadsheet, and hundreds of other things. We hope to supply, eventually, everything useful that normally comes with a Unix system, and anything else useful, including on-line and hardcopy documentation.<br>
</p>
在这个设想宏大的GNU计划里包括了操作系统内核、编辑器、shell、编译器、链接器和汇编器等等每一个都是极其难啃的硬骨头。
不过斯托曼可是个牛人单枪匹马地开发出世界上最牛的编辑器Emacs继而组织和成立了自由软件基金会the Free Software Foundation - FSF
GNU在自由软件基金会统一组织下相继推出了编译器GCC、调试器GDB、Bash Shell等运行于用户空间的程序。正是这些软件为Linux 操作系统的开发创造了一个合适的环境比如编译器GCC、Bash Shell就是Linux能够诞生的基础之一。
你有没有发现GNU独缺操作系统核心
实际上1990年自由软件基金会开始正式发展自己的操作系统Hurd作为GNU项目中的操作系统。不过这个项目再三耽搁1991年Linux出现1993年FreeBSD发布这样GNU的开发者开始转向于Linux或FreeBSD其中Linux成为更常见的GNU软件运行平台。
斯托曼主张Linux操作系统使用了许多GNU软件正式名应为GNU/Linux但没有得到Linux社群的一致认同形成著名的GNU/Linux命名争议。
GNU是这么解释为什么应该叫GNU/Linux的“大多数基于Linux内核发布的操作系统基本上都是GNU操作系统的修改版。我们从1984 年就开始编写GNU 软件要比Linus开始编写它的内核早许多年而且我们开发了系统的大部分软件要比其它项目多得多我们应该得到公平对待。”
从这段话里我们可以知道GNU和GNU/Linux互相造就了对方没有GNU当然没有Linux不过没有LinuxGNU也不可能大发光彩。
在开源的世界里,也会发生这种争名夺利的事情,我们也不用觉得惊奇。
## 操作系统对TCP/IP的支持
讲了这么多操作系统的内容我们再来看下面这张图。图中展示了TCP/IP在各大操作系统的演变历史。可以看到即使是大名鼎鼎的Linux以及90年代大发光彩的Windows操作系统在TCP/IP网络这块也只能算是一个后来者。
<img src="https://static001.geekbang.org/resource/image/0f/e1/0f783e74927d70794421cf5983f22ae1.png" alt="">
## 总结
这是我们专栏的第一讲我没有直接开始讲网络编程而是对今天互联网技术的基石TCP和Linux进行了简单的回顾。通过这样的回顾熟悉历史可以指导我们今后学习的方向在后面的章节中我们都将围绕Linux下的TCP/IP程序设计展开。
最后你不妨思考一下Linux TCP/IP网络协议栈最初的实现“借鉴”了多少BSD的实现呢Linux到底是不是应该被称为GNU/Linux呢
欢迎你在评论区写下你的思考我会和你一起讨论这些问题。如果这篇文章帮你厘清了TCP/IP和Linux的发展脉络欢迎把它分享给你的朋友或者同事。

View File

@@ -0,0 +1,151 @@
<audio id="audio" title="02 | 网络编程模型:认识客户端-服务器网络模型的基本概念" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6a/0e/6afd59f889f555ea7f7a5693d942c70e.mp3"></audio>
你好我是盛延敏。上一讲我们学习了TCP/IP的创建和历史以及Linux操作系统的建立和发展相信你对网络编程这棵大树已经有了一个宏观上的认识那么今天我们再往前走几步近距离看看这棵大树的细枝末节到底是怎样的。
从哪里开始呢?从网络编程的基本概念开始说起吧。
## 客户端-服务器网络编程模型
在谈论网络编程时,我们首先需要建立一个概念,也就是我们今天的主题“客户端-服务器”。
拿我们常用的网络购物来说,我们在手机上的每次操作,都是作为客户端向服务器发送请求,并收到响应的例子。
这个过程具体阐释如下:
<img src="https://static001.geekbang.org/resource/image/78/83/78e415180d2946c418485d30f3f78f83.png" alt="">
1. 当一个客户端需要服务时,比如网络购物下单,它会向服务器端发送一个请求。注意,这个请求是按照双方约定的格式来发送的,以便保证服务器端是可以理解的;
1. 服务器端收到这个请求后,会根据双方约定的格式解释它,并且以合适的方式进行操作,比如调用数据库操作来创建一个购物单;
1. 服务器端完成处理请求之后,会给客户端发送一个响应,比如向客户端发送购物单的实际付款额,然后等待客户端的下一步操作;
1. 客户端收到响应并进行处理,比如在手机终端上显示该购物单的实际付款额,并且让用户选择付款方式。
在网络编程中,具体到客户端-服务器模型时我们经常会考虑是使用TCP还是UDP其实它们二者的区别也很简单TCP中连接是谁发起的在UDP中报文是谁发送的。在TCP通信中建立连接是一个非常重要的环节。区别出客户端和服务器本质上是因为二者编程模型是不同的。
服务器端需要在一开始就监听在一个众所周知的端口上,等待客户端发送请求,一旦有客户端连接建立,服务器端就会消耗一定的计算机资源为它服务,服务器端是需要同时为成千上万的客户端服务的。如何保证服务器端在数据量巨大的客户端访问时依然能维持效率和稳定,这也是我们讲述高性能网络编程的目的。
客户端相对来说更为简单,它向服务器端的监听端口发起连接请求,连接建立之后,通过连接通路和服务器端进行通信。
**还有一点需要强调的是无论是客户端还是服务器端它们运行的单位都是进程process而不是机器**。一个客户端比如我们的手机终端同一个时刻可以建立多个到不同服务器的连接比如同时打游戏上知乎逛天猫而服务器端更是可能在一台机器上部署运行了多个服务比如同时开启了SSH服务和HTTP服务。
## IP和端口
正如寄信需要一个地址一样在网络世界里同样也需要地址的概念。在TCP/IP协议栈中IP用来表示网络世界的地址。
前面我们提到了,在一台计算机上是可以同时存在多个连接的,那么如何区分出不同的连接呢?
这里就必须提到端口这个概念。我们拿住酒店举例子酒店的地址是唯一的每间房间的号码是不同的类似的计算机的IP地址是唯一的每个连接的端口号是不同的。
端口号是一个16位的整数最多为65536。当一个客户端发起连接请求时客户端的端口是由操作系统内核临时分配的称为临时端口然而前面也提到过服务器端的端口通常是一个众所周知的端口。
一个连接可以通过客户端-服务器端的IP和端口唯一确定这叫做套接字对按照下面的四元组表示
```
clientaddr:clientport, serveraddr: serverport)
```
下图表示了一个客户端-服务器之间的连接:
<img src="https://static001.geekbang.org/resource/image/54/2a/543b5488f9422558069df507cfaa462a.png" alt="">
## 保留网段
一个比较常见的现象是我们所在的单位或者组织普遍会使用诸如10.0.x.x或者192.168.x.x这样的IP地址你可能会纳闷这样的IP到底代表了什么呢不同的组织使用同样的IP会不会导致冲突呢?
背后的原因是这样的国际标准组织在IPv4地址空间里面专门划出了一些网段这些网段不会用做公网上的IP而是仅仅保留作内部使用我们把这些地址称作保留网段。
下表是三个保留网段其可以容纳的计算机主机个数分别是16777216个、1048576个和65536个。
<img src="https://static001.geekbang.org/resource/image/80/ef/8062576bcd515e1c84cec960e4796fef.png" alt=""><br>
在详细讲述这个表格之前,我们需要首先了解一下子网掩码的概念。
## 子网掩码
在网络IP划分的时候我们需要区分两个概念。
第一是网络network的概念直观点说它表示的是这组IP共同的部分比如在192.168.1.1~192.168.1.255这个区间里它们共同的部分是192.168.1.0。
第二是主机host的概念它表示的是这组IP不同的部分上面的例子中1~255就是不同的那些部分表示有255个可用的不同IP。
例如IPv4地址192.0.2.12,我们可以说前面三个 bytes 是子网,最后一个 byte 是 host或者换个方式我们能说 host 为8 位子网掩码为192.0.2.0/24255.255.255.0)。
有点晕?别着急,接下来要讲的是一些基本概念。
很久很久以前有子网subnet的分类在这里一个IPv4地址的第一个前两个或前三个 字节是属于网络的一部分。
如果你很幸运地可以拥有一个字节的网络,而另外三个字节是 host 地址那在你的网络里你有价值三个字节也就是24个比特的主机地址这是什么概念呢 2的24次方大约是一千六百万个地址左右。这是一个“Class A”A 类)网络。
<img src="https://static001.geekbang.org/resource/image/80/ef/8062576bcd515e1c84cec960e4796fef.png" alt=""><br>
我们再来重新看一下这张表格表格第一行就是这样的一个A类网络10是对应的网络字节部分主机的字节是3我们将一个字节的子网记作255.0.0.0。
相对的“Class B”B 类)的网络,网络有两个字节,而 host 只有两个字节也就是说拥有的主机个数为65536。“Class C”C 类)的网络,网络有三个 字节,而 host 只有一个 字节也就是说拥有的主机个数为256。
网络地址位数由子网掩码Netmask决定你可以将IP地址与子网掩码进行“位与”操作就能得到网络的值。子网掩码一般看起来像是 255.255.255.0二进制为11111111.11111111.11111111.00000000比如你的IP是192.0.2.12使用这个子网掩码时你的网络就会是192.0.2.12与255.255.255.0所得到的值192.0.2.0192.0.2.0就是这个网络的值。
子网掩码能接受任意个位而不单纯是上面讨论的816或24个比特而已。所以你可以有一个子网掩码255.255.255.252二进制位11111111.11111111.11111111.11111100这个子网掩码能切出一个30个位的网络以及2个位的主机这个网络最多有四台 host。为什么是4台host呢因为变化的部分只有最后两位所有的可能为2的2次方即4台host。
注意,子网掩码的格式永远都是二进制格式:前面是一连串的 1后面跟着一连串的 0。
不过一大串的数字会有点不好用,比如像 255.192.0.0 这样的子网掩码人们无法直观地知道有多少个1多少个0后来人们发明了新的办法你只需要将一个斜线放在IP地址后面接着用一个十进制的数字用以表示网络的位数类似这样192.0.2.12/30, 这样就很容易知道有30个1 2个0所以主机个数为4。
相信这个时候再去看保留网段,你应该能理解表格里的内容了。这里就不再赘述。
## 全球域名系统
如果每次要访问一个服务都要记下这个服务对应的IP地址无疑是一种枯燥而繁琐的事情就像你要背下200多个好友的电话号码一般无聊。
此时你应该知道我将要表达什么。对的正如电话簿记录了好友和电话的对应关系一样域名DNS也记录了网站和IP的对应关系。
全球域名按照从大到小的结构,形成了一棵树状结构。实际访问一个域名时,是从最底层开始写起,例如 [www.google.com](http://www.google.com,)[www.tinghua.edu.cn](http://www.tinghua.edu.cn)等。
<img src="https://static001.geekbang.org/resource/image/23/be/23dc0a68d6016b71365e62879a3a6cbe.jpg" alt="">
## 数据报和字节流
尽管名称是TCP/IP协议栈但是从上一讲关于OSI和TCP/IP协议栈的对比中我们看到传输层其实是有两种协议的一种是大家广为熟悉的TCP 而另一种就是UDP。
TCP又被叫做字节流套接字Stream Socket注意我们这里先引入套接字socket套接字socket在后面几讲中将被反复提起因为它实际上是网络编程的核心概念。当然UDP也有一个类似的叫法, 数据报套接字Datagram Socket一般分别以“SOCK_STREAM”与“SOCK_DGRAM”分别来表示TCP和UDP套接字。
Datagram Sockets 有时称为“无连接的sockets”connectionless sockets
Stream sockets 是可靠的、双向连接的通讯串流。比如以“1-2-3”的顺序将字节流输出到套接字上它们在另一端一定会以“1-2-3”的顺序抵达而且不会出错。
这种高质量的通信是如何办到的呢这就是由TCPTransmission Control Protocol协议完成的TCP通过诸如连接管理拥塞控制数据流与窗口管理超时和重传等一系列精巧而详细的设计提供了高质量的端到端的通信方式。
这部分内容不是我们这里讲解的重点有感兴趣的同学可以去读《TCP/IP详解卷一协议》 。
我们平时使用浏览器访问网页或者在手机端用天猫App购物时使用的都是字节流套接字。
等等如果是这样世界都用TCP好了哪里有UDP什么事呢
事实上UDP在很多场景也得到了极大的应用比如多人联网游戏、视频会议甚至聊天室。如果你听说过NTP你一定很惊讶NTP也是用UDP实现的。
使用UDP的原因第一是速度第二还是速度。
想象一下一个有上万人的联网游戏如果要给每个玩家同步游戏中其他玩家的位置信息而且丢失一两个也不会造成多大的问题那么UDP是一个比较经济合算的选择。
还有一种叫做广播或多播的技术就是向网络中的多个节点同时发送信息这个时候选择UDP更是非常合适的。
UDP也可以做到更高的可靠性只不过这种可靠性需要应用程序进行设计处理比如对报文进行编号设计Request-Ack机制再加上重传等在一定程度上可以达到更为高可靠的UDP程序。当然这种可靠性和TCP相比还是有一定的距离不过也可以弥补实战中UDP的一些不足。
在后面的章节中我们将会分别介绍TCP和UDP的网络编程技术。
## 总结
这一讲我们主要介绍了客户端-服务器网络编程模型初步介绍了IP地址、端口、子网掩码和域名等基础概念以下知识点你需要重点关注一下
1. 网络编程需要牢牢建立起“客户端”和“服务器”模型,两者编程的方法和框架是明显不同的。
1. TCP连接是客户端-服务器的IP和端口四元组唯一确定的IP是一台机器在网络世界的唯一标识。
1. 有两种截然不同的传输层协议面向连接的“数据流”协议TCP以及无连接的“数据报”协议UDP。
从下一讲开始,我们将开始使用套接字编写我们的第一个客户端-服务器程序。
## 思考题
最后给你布置几个思考题。
我们看到保留地址中第二行172.16.0.0/12描述为16个连续的B段第三行192.168.0.0/16描述为256个连续的C段地址怎么理解这种描述呢
另外,章节里提到了服务端必须侦听在一个众所周知的端口上,这个端口怎么选择,又是如何让客户端知道的呢?
如果你仔细想过这个问题,欢迎在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,大家一起交流一下。

View File

@@ -0,0 +1,219 @@
<audio id="audio" title="03丨套接字和地址像电话和电话号码一样理解它们" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/63/1f/6315819f6bff64a0a17b3a8d8783021f.mp3"></audio>
在网络编程中我们经常会提到socket这个词它的中文翻译为套接字有的时候也叫做套接口。
socket这个英文单词的原意是“插口”“插槽” 在网络编程中它的寓意是可以通过插口接入的方式快速完成网络连接和数据收发。你可以把它想象成现实世界的电源插口或者是早期上网需要的网络插槽所以socket也可以看做是对物理世界的直接映射。
其实计算机程序设计是一门和英文有着紧密联系的学科,很多专有名词使用英文原词比翻译成中文更容易让大家接受。为了方便,在专栏里我们一般会直接使用英文,如果需要翻译就一律用“套接字”这个翻译。
## socket到底是什么
在网络编程中到底应该怎么理解socket呢我在这里先呈上这么一张图你可以先看看。
<img src="https://static001.geekbang.org/resource/image/0b/64/0ba3f3d04b1466262c02d6f24ee76a64.jpg" alt=""><br>
这张图表达的其实是网络编程中,客户端和服务器工作的核心逻辑。
我们先从右侧的服务器端开始看因为在客户端发起连接请求之前服务器端必须初始化好。右侧的图显示的是服务器端初始化的过程首先初始化socket之后服务器端需要执行bind函数将自己的服务能力绑定在一个众所周知的地址和端口上紧接着服务器端执行listen操作将原先的socket转化为服务端的socket服务端最后阻塞在accept上等待客户端请求的到来。
此时服务器端已经准备就绪。客户端需要先初始化socket再执行connect向服务器端的地址和端口发起连接请求这里的地址和端口必须是客户端预先知晓的。这个过程就是著名的**TCP三次握手**Three-way Handshake。下一篇文章我会详细讲到TCP三次握手的原理。
一旦三次握手完成,客户端和服务器端建立连接,就进入了数据传输过程。
具体来说客户端进程向操作系统内核发起write字节流写操作内核协议栈将字节流通过网络设备传输到服务器端服务器端从内核得到信息将字节流从内核读入到进程中并开始业务逻辑的处理完成之后服务器端再将得到的结果以同样的方式写给客户端。可以看到**一旦连接建立数据的传输就不再是单向的而是双向的这也是TCP的一个显著特性**。
当客户端完成和服务器端的交互后比如执行一次Telnet操作或者一次HTTP请求需要和服务器端断开连接时就会执行close函数操作系统内核此时会通过原先的连接链路向服务器端发送一个FIN包服务器收到之后执行被动关闭这时候整个链路处于半关闭状态此后服务器端也会执行close函数整个链路才会真正关闭。半关闭的状态下发起close请求的一方在没有收到对方FIN包之前都认为连接是正常的而在全关闭的状态下双方都感知连接已经关闭。
请你牢牢记住文章开头的那幅图,它是贯穿整个专栏的核心图之一。
讲这幅图的真正用意在于引入socket的概念请注意以上所有的操作都是通过socket来完成的。无论是客户端的connect还是服务端的accept或者read/write操作等**socket是我们用来建立连接传输数据的唯一途径**。
### 更好地理解socket一个更直观的解释
你可以把整个TCP的网络交互和数据传输想象成打电话顺着这个思路想象socket就好像是我们手里的电话机connect就好比拿着电话机拨号而服务器端的bind就好比是去电信公司开户将电话号码和我们家里的电话机绑定这样别人就可以用这个号码找到你listen就好似人们在家里听到了响铃accept就好比是被叫的一方拿起电话开始应答。至此三次握手就完成了连接建立完毕。
接下来拨打电话的人开始说话“你好。”这时就进入了write接收电话的人听到的过程可以想象成read听到并读出数据并且开始应答双方就进入了read/write的数据传输过程。
最后拨打电话的人完成了此次交流挂上电话对应的操作可以理解为close接听电话的人知道对方已挂机也挂上电话也是一次close。
在整个电话交流过程中电话是我们可以和外面通信的设备对应到网络编程的世界里socket也是我们可以和外界进行网络通信的途径。
### socket的发展历史
通过上面的讲解和这个打电话的类比你现在清楚socket到底是什么了吧那socket最开始是怎么被提出来的呢接下来就很有必要一起来简单追溯一下它的历史了。
socket是加州大学伯克利分校的研究人员在20世纪80年代早期提出的所以也被叫做伯克利套接字。伯克利的研究者们设想用socket的概念屏蔽掉底层协议栈的差别。第一版实现socket的就是TCP/IP协议最早是在BSD 4.2 Unix内核上实现了socket。很快大家就发现这么一个概念带来了网络编程的便利于是有更多人也接触到了socket的概念。Linux作为Unix系统的一个开源实现很早就从头开发实现了TCP/IP协议伴随着socket的成功Windows也引入了socket的概念。于是在今天的世界里socket成为网络互联互通的标准。
## 套接字地址格式
在使用套接字时,首先要解决通信双方寻址的问题。我们需要套接字的地址建立连接,就像打电话时首先需要查找电话簿,找到你想要联系的那个人,你才可以建立连接,开始交流。接下来,我们重点讨论套接字的地址格式。
### 通用套接字地址格式
下面先看一下套接字的**通用**地址结构:
```
/* POSIX.1g 规范规定了地址族为2字节的值. */
typedef unsigned short int sa_family_t;
/* 描述通用套接字地址 */
struct sockaddr{
sa_family_t sa_family; /* 地址族. 16-bit*/
char sa_data[14]; /* 具体的地址值 112-bit */
};
```
在这个结构体里第一个字段是地址族它表示使用什么样的方式对地址进行解释和保存好比电话簿里的手机格式或者是固话格式这两种格式的长度和含义都是不同的。地址族在glibc里的定义非常多常用的有以下几种
- AF_LOCAL表示的是本地地址对应的是Unix套接字这种情况一般用于本地socket通信很多情况下也可以写成AF_UNIX、AF_FILE
- AF_INET因特网使用的IPv4地址
- AF_INET6因特网使用的IPv6地址。
这里的AF_表示的含义是Address Family但是很多情况下我们也会看到以PF_表示的宏比如PF_INET、PF_INET6等实际上PF_的意思是Protocol Family也就是协议族的意思。我们用AF_xxx这样的值来初始化socket地址用PF_xxx这样的值来初始化socket。我们在&lt;sys/socket.h&gt;头文件中可以清晰地看到,这两个值本身就是一一对应的。
```
/* 各种地址族的宏定义 */
#define AF_UNSPEC PF_UNSPEC
#define AF_LOCAL PF_LOCAL
#define AF_UNIX PF_UNIX
#define AF_FILE PF_FILE
#define AF_INET PF_INET
#define AF_AX25 PF_AX25
#define AF_IPX PF_IPX
#define AF_APPLETALK PF_APPLETALK
#define AF_NETROM PF_NETROM
#define AF_BRIDGE PF_BRIDGE
#define AF_ATMPVC PF_ATMPVC
#define AF_X25 PF_X25
#define AF_INET6 PF_INET6
```
sockaddr是一个通用的地址结构通用的意思是适用于多种地址族。为什么定义这么一个通用地址结构呢这个放在后面讲。
### IPv4套接字格式地址
接下来看一下常用的IPv4地址族的结构
```
/* IPV4套接字地址32bit值. */
typedef uint32_t in_addr_t;
struct in_addr
{
in_addr_t s_addr;
};
/* 描述IPV4的套接字地址格式 */
struct sockaddr_in
{
sa_family_t sin_family; /* 16-bit */
in_port_t sin_port; /* 端口号 16-bit*/
struct in_addr sin_addr; /* Internet address. 32-bit */
/* 这里仅仅用作占位符,不做实际用处 */
unsigned char sin_zero[8];
};
```
我们对这个结构体稍作解读首先可以发现和sockaddr一样都有一个16-bit的sin_family字段对于IPv4来说这个值就是AF_INET。
接下来是端口号我们可以看到端口号最多是16-bit也就是说最大支持2的16次方这个数字是65536所以我们应该知道支持寻址的端口号最多就是65535。关于端口我在前面的章节也提到过这里重点阐述一下保留端口。所谓保留端口就是大家约定俗成的已经被对应服务广为使用的端口比如ftp的21端口ssh的22端口http的80端口等。一般而言大于5000的端口可以作为我们自己应用程序的端口使用。
下面是glibc定义的保留端口。
```
/* Standard well-known ports. */
enum
{
IPPORT_ECHO = 7, /* Echo service. */
IPPORT_DISCARD = 9, /* Discard transmissions service. */
IPPORT_SYSTAT = 11, /* System status service. */
IPPORT_DAYTIME = 13, /* Time of day service. */
IPPORT_NETSTAT = 15, /* Network status service. */
IPPORT_FTP = 21, /* File Transfer Protocol. */
IPPORT_TELNET = 23, /* Telnet protocol. */
IPPORT_SMTP = 25, /* Simple Mail Transfer Protocol. */
IPPORT_TIMESERVER = 37, /* Timeserver service. */
IPPORT_NAMESERVER = 42, /* Domain Name Service. */
IPPORT_WHOIS = 43, /* Internet Whois service. */
IPPORT_MTP = 57,
IPPORT_TFTP = 69, /* Trivial File Transfer Protocol. */
IPPORT_RJE = 77,
IPPORT_FINGER = 79, /* Finger service. */
IPPORT_TTYLINK = 87,
IPPORT_SUPDUP = 95, /* SUPDUP protocol. */
IPPORT_EXECSERVER = 512, /* execd service. */
IPPORT_LOGINSERVER = 513, /* rlogind service. */
IPPORT_CMDSERVER = 514,
IPPORT_EFSSERVER = 520,
/* UDP ports. */
IPPORT_BIFFUDP = 512,
IPPORT_WHOSERVER = 513,
IPPORT_ROUTESERVER = 520,
/* Ports less than this value are reserved for privileged processes. */
IPPORT_RESERVED = 1024,
/* Ports greater this value are reserved for (non-privileged) servers. */
IPPORT_USERRESERVED = 5000
```
实际的IPv4地址是一个32-bit的字段可以想象最多支持的地址数就是2的32次方大约是42亿应该说这个数字在设计之初还是非常巨大的无奈互联网蓬勃发展全球接入的设备越来越多这个数字渐渐显得不太够用了于是大家所熟知的IPv6就隆重登场了。
### IPv6套接字地址格式
我们再看看IPv6的地址结构
```
struct sockaddr_in6
{
sa_family_t sin6_family; /* 16-bit */
in_port_t sin6_port; /* 传输端口号 # 16-bit */
uint32_t sin6_flowinfo; /* IPv6流控信息 32-bit*/
struct in6_addr sin6_addr; /* IPv6地址128-bit */
uint32_t sin6_scope_id; /* IPv6域ID 32-bit */
};
```
整个结构体长度是28个字节其中流控信息和域ID先不用管这两个字段一个在glibc的官网上根本没出现另一个是当前未使用的字段。这里的地址族显然应该是AF_INET6端口同IPv4地址一样关键的地址从32位升级到128位这个数字就大到恐怖了完全解决了寻址数字不够的问题。
请注意以上无论IPv4还是IPv6的地址格式都是因特网套接字的格式还有一种本地套接字格式用来作为本地进程间的通信 也就是前面提到的AF_LOCAL。
```
struct sockaddr_un {
unsigned short sun_family; /* 固定为 AF_LOCAL */
char sun_path[108]; /* 路径名 */
};
```
### 几种套接字地址格式比较
这几种地址的比较见下图IPv4和IPv6套接字地址结构的长度是固定的而本地地址结构的长度是可变的。
<img src="https://static001.geekbang.org/resource/image/ed/58/ed49b0f1b658e82cb07a6e1e81f36b58.png" alt="">
## 总结
这一讲我们重点讲述了什么是套接字以及对应的套接字地址格式。套接字作为网络编程的基础概念异常重要。套接字的设计为我们打开了网络编程的大门实际上正是因为BSD套接字如此成功各大Unix厂商包括开源的Linux以及Windows平台才会很快照搬了过来。在下一讲中我们将开始创建并使用套接字建立连接进一步开始我们的网络编程之旅。
## 思考题
最后给你留两道思考题吧你可以想一想IPv4、IPv6、本地套接字格式以及通用地址套接字它们有什么共性呢如果你是BSD套接字的设计者你为什么要这样设计呢
第二道题是为什么本地套接字格式不需要端口号而IPv4和IPv6套接字格式却需要端口号呢
我在评论区期待你的思考与见解,如果你觉得这篇文章对你有所帮助,欢迎点击“请朋友读”,把这篇文章分享给你朋友或同事。

View File

@@ -0,0 +1,229 @@
<audio id="audio" title="04 | TCP三次握手怎么使用套接字格式建立连接" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f2/3e/f2354a6313ce726e7f3b86cf1428ca3e.mp3"></audio>
你好我是盛延敏这里是网络编程实战第4讲欢迎回来。
在上一讲里我们介绍了IPv4、IPv6以及本地套接字格式这一讲我们来讲一讲怎么使用这些套接字格式完成连接的建立当然经典的TCP三次握手理论也会贯穿其中。我希望经过这一讲的讲解你会牢牢记住TCP三次握手和客户端、服务器模型。
让我们先从服务器端开始。
## 服务端准备连接的过程
### 创建套接字
要创建一个可用的套接字,需要使用下面的函数:
```
int socket(int domain, int type, int protocol)
```
domain就是指PF_INET、PF_INET6以及PF_LOCAL等表示什么样的套接字。
type可用的值是
- **SOCK_STREAM: 表示的是字节流对应TCP**
- **SOCK_DGRAM 表示的是数据报对应UDP**
- **SOCK_RAW: 表示的是原始套接字。**
参数protocol原本是用来指定通信协议的但现在基本废弃。因为协议已经通过前面两个参数指定完成。protocol目前一般写成0即可。
### bind: 设定电话号码
创建出来的套接字如果需要被别人使用就需要调用bind函数把套接字和套接字地址绑定就像去电信局登记我们的电话号码一样。
调用bind函数的方式如下
```
bind(int fd, sockaddr * addr, socklen_t len)
```
我们需要注意到bind函数后面的第二个参数是通用地址格式`sockaddr * addr`。这里有一个地方值得注意那就是虽然接收的是通用地址格式实际上传入的参数可能是IPv4、IPv6或者本地套接字格式。bind函数会根据len字段判断传入的参数addr该怎么解析len字段表示的就是传入的地址长度它是一个可变值。
这里其实可以把bind函数理解成这样
```
bind(int fd, void * addr, socklen_t len)
```
不过BSD设计套接字的时候大约是1982年那个时候的C语言还没有`void *`的支持为了解决这个问题BSD的设计者们创造性地设计了通用地址格式来作为支持bind和accept等这些函数的参数。
对于使用者来说每次需要将IPv4、IPv6或者本地套接字格式转化为通用套接字格式就像下面的IPv4套接字地址格式的例子一样
```
struct sockaddr_in name;
bind (sock, (struct sockaddr *) &amp;name, sizeof (name)
```
对于实现者来说可根据该地址结构的前两个字节判断出是哪种地址。为了处理长度可变的结构需要读取函数里的第三个参数也就是len字段这样就可以对地址进行解析和判断了。
设置bind的时候对地址和端口可以有多种处理方式。
我们可以把地址设置成本机的IP地址这相当告诉操作系统内核仅仅对目标IP是本机IP地址的IP包进行处理。但是这样写的程序在部署时有一个问题我们编写应用程序时并不清楚自己的应用程序将会被部署到哪台机器上。这个时候可以利用**通配地址**的能力帮助我们解决这个问题。通配地址相当于告诉操作系统内核“Hi我可不挑活只要目标地址是咱们的都可以。”比如一台机器有两块网卡IP地址分别是202.61.22.55和192.168.1.11那么向这两个IP请求的请求包都会被我们编写的应用程序处理。
那么该如何设置通配地址呢?
对于IPv4的地址来说使用INADDR_ANY来完成通配地址的设置对于IPv6的地址来说使用IN6ADDR_ANY来完成通配地址的设置。
```
struct sockaddr_in name;
name.sin_addr.s_addr = htonl (INADDR_ANY); /* IPV4通配地址 */
```
除了地址还有端口。如果把端口设置成0就相当于把端口的选择权交给操作系统内核来处理操作系统内核会根据一定的算法选择一个空闲的端口完成套接字的绑定。这在服务器端不常使用。
一般来说服务器端的程序一定要绑定到一个众所周知的端口上。服务器端的IP地址和端口数据相当于打电话拨号时需要知道的对方号码如果没有电话号码就没有办法和对方建立连接。
我们来看一个初始化IPv4 TCP 套接字的例子:
```
#include &lt;stdio.h&gt;
#include &lt;stdlib.h&gt;
#include &lt;sys/socket.h&gt;
#include &lt;netinet/in.h&gt;
int make_socket (uint16_t port)
{
int sock;
struct sockaddr_in name;
/* 创建字节流类型的IPV4 socket. */
sock = socket (PF_INET, SOCK_STREAM, 0);
if (sock &lt; 0)
{
perror (&quot;socket&quot;);
exit (EXIT_FAILURE);
}
/* 绑定到port和ip. */
name.sin_family = AF_INET; /* IPV4 */
name.sin_port = htons (port); /* 指定端口 */
name.sin_addr.s_addr = htonl (INADDR_ANY); /* 通配地址 */
/* 把IPV4地址转换成通用地址格式同时传递长度 */
if (bind (sock, (struct sockaddr *) &amp;name, sizeof (name)) &lt; 0)
{
perror (&quot;bind&quot;);
exit (EXIT_FAILURE);
}
return sock
}
```
### listen接上电话线一切准备就绪
bind函数只是让我们的套接字和地址关联如同登记了电话号码。如果要让别人打通电话还需要我们把电话设备接入电话线让服务器真正处于可接听的状态这个过程需要依赖listen函数。
初始化创建的套接字,可以认为是一个"主动"套接字其目的是之后主动发起请求通过调用connect函数后面会讲到。通过listen函数可以将原来的"主动"套接字转换为"被动"套接字,告诉操作系统内核:“我这个套接字是用来等待用户请求的。”当然,操作系统内核会为此做好接收用户请求的一切准备,比如完成连接队列。
listen函数的原型是这样的
```
int listen (int socketfd, int backlog)
```
我来稍微解释一下。第一个参数socketdf为套接字描述符第二个参数backlog在Linux中表示已完成(ESTABLISHED)且未accept的队列大小这个参数的大小决定了可以接收的并发数目。这个参数越大并发数目理论上也会越大。但是参数过大也会占用过多的系统资源一些系统比如Linux并不允许对这个参数进行改变。对于backlog整个参数的设置有一些最佳实践这里就不展开后面结合具体的实例进行解读。
### accept: 电话铃响起了……
当客户端的连接请求到达时,服务器端应答成功,连接建立,这个时候操作系统内核需要把这个事件通知到应用程序,并让应用程序感知到这个连接。这个过程,就好比电信运营商完成了一次电话连接的建立, 应答方的电话铃声响起,通知有人拨打了号码,这个时候就需要拿起电话筒开始应答。
连接建立之后你可以把accept这个函数看成是操作系统内核和应用程序之间的桥梁。它的原型是
```
int accept(int listensockfd, struct sockaddr *cliaddr, socklen_t *addrlen)
```
函数的第一个参数listensockfd是套接字可以叫它为listen套接字因为这就是前面通过bindlisten一系列操作而得到的套接字。函数的返回值有两个部分第一个部分cliadd是通过指针方式获取的客户端的地址addrlen告诉我们地址的大小这可以理解成当我们拿起电话机时看到了来电显示知道了对方的号码另一个部分是函数的返回值这个返回值是一个全新的描述字代表了与客户端的连接。
这里一定要注意有两个套接字描述字第一个是监听套接字描述字listensockfd它是作为输入参数存在的第二个是返回的已连接套接字描述字。
你可能会问,为什么要把两个套接字分开呢?用一个不是挺好的么?
这里和打电话的情形非常不一样的地方就在于,打电话一旦有一个连接建立,别人是不能再打进来的,只会得到语音播报:“您拨的电话正在通话中。”而网络程序的一个重要特征就是并发处理,不可能一个应用程序运行之后只能服务一个客户,如果是这样, 双11抢购得需要多少服务器才能满足全国 “剁手党 ” 的需求?
所以监听套接字一直都存在它是要为成千上万的客户来服务的直到这个监听套接字关闭而一旦一个客户和服务器连接成功完成了TCP三次握手操作系统内核就为这个客户生成一个已连接套接字让应用服务器使用这个**已连接套接字**和客户进行通信处理。如果应用服务器完成了对这个客户的服务,比如一次网购下单,一次付款成功,那么关闭的就是**已连接套接字**这样就完成了TCP连接的释放。请注意这个时候释放的只是这一个客户连接其它被服务的客户连接可能还存在。最重要的是监听套接字一直都处于“监听”状态等待新的客户请求到达并服务。
## 客户端发起连接的过程
前面讲述的bind、listen以及accept的过程是典型的服务器端的过程。下面我来讲下客户端发起连接请求的过程。
第一步还是和服务端一样,要建立一个套接字,方法和前面是一样的。
不一样的是客户端需要调用connect向服务端发起请求。
### connect: 拨打电话
客户端和服务器端的连接建立是通过connect函数完成的。这是connect的构建函数
```
int connect(int sockfd, const struct sockaddr *servaddr, socklen_t addrlen)
```
函数的第一个参数sockfd是连接套接字通过前面讲述的socket函数创建。第二个、第三个参数servaddr和addrlen分别代表指向套接字地址结构的指针和该结构的大小。套接字地址结构必须含有服务器的IP地址和端口号。
客户在调用函数connect前不必非得调用bind函数因为如果需要的话内核会确定源IP地址并按照一定的算法选择一个临时端口作为源端口。
如果是TCP套接字那么调用connect函数将激发TCP的三次握手过程而且仅在连接建立成功或出错时才返回。其中出错返回可能有以下几种情况
1. 三次握手无法建立客户端发出的SYN包没有任何响应于是返回TIMEOUT错误。这种情况比较常见的原因是对应的服务端IP写错。
1. 客户端收到了RST复位回答这时候客户端会立即返回CONNECTION REFUSED错误。这种情况比较常见于客户端发送连接请求时的请求端口写错因为RST是TCP在发生错误时发送的一种TCP分节。产生RST的三个条件是目的地为某端口的SYN到达然而该端口上没有正在监听的服务器如前所述TCP想取消一个已有连接TCP接收到一个根本不存在的连接上的分节。
1. 客户发出的SYN包在网络上引起了"destination unreachable",即目的不可达的错误。这种情况比较常见的原因是客户端和服务器端路由不通。
根据不同的返回值,我们可以做进一步的排查。
## 著名的TCP三次握手: 这一次不用背记
<img src="https://static001.geekbang.org/resource/image/65/29/65cef2c44480910871a0b66cac1d5529.png" alt=""><br>
你在各个场合都会了解到著名的TCP三次握手可能还会被要求背下三次握手整个过程但背后的原理和过程可能未必真正理解。我们刚刚学习了服务端和客户端连接的主要函数下面结合这些函数讲解一下TCP三次握手的过程。这样我相信你不用背也能根据理解轻松掌握这部分的知识。
这里我们使用的网络编程模型都是阻塞式的。所谓阻塞式,就是调用发起后不会直接返回,由操作系统内核处理之后才会返回。 相对的,还有一种叫做非阻塞式的,我们在后面的章节里会讲到。
## TCP三次握手的解读
我们先看一下最初的过程服务器端通过socketbind和listen完成了被动套接字的准备工作被动的意思就是等着别人来连接然后调用accept就会阻塞在这里等待客户端的连接来临客户端通过调用socket和connect函数之后也会阻塞。接下来的事情是由操作系统内核完成的更具体一点的说是操作系统内核网络协议栈在工作。
下面是具体的过程:
1. 客户端的协议栈向服务器端发送了SYN包并告诉服务器端当前发送序列号j客户端进入SYNC_SENT状态
1. 服务器端的协议栈收到这个包之后和客户端进行ACK应答应答的值为j+1表示对SYN包j的确认同时服务器也发送一个SYN包告诉客户端当前我的发送序列号为k服务器端进入SYNC_RCVD状态
1. 客户端协议栈收到ACK之后使得应用程序从connect调用返回表示客户端到服务器端的单向连接建立成功客户端的状态为ESTABLISHED同时客户端协议栈也会对服务器端的SYN包进行应答应答数据为k+1
1. 应答包到达服务器端后服务器端协议栈使得accept阻塞调用返回这个时候服务器端到客户端的单向连接也建立成功服务器端也进入ESTABLISHED状态。
形象一点的比喻是这样的有A和B想进行通话
- A先对B说“喂你在么我在的我的口令是j。”
- B收到之后大声回答“我收到你的口令j并准备好了你准备好了吗我的口令是k。”
- A收到之后也大声回答“我收到你的口令k并准备好了我们开始吧。”
可以看到这样的应答过程总共进行了三次这就是TCP连接建立之所以被叫为“三次握手”的原因了。
## 总结
这一讲我们分别从服务端和客户端的角度讲述了如何创建套接字并利用套接字完成TCP连接的建立。
- 服务器端通过创建socketbindlisten完成初始化通过accept完成连接的建立。
- 客户端通过创建socketconnect发起连接建立请求。
在下一讲里,我们将真正地开始客户端-服务端数据交互的过程。
## 思考题
最后给你布置两道思考题。
第一道是关于阻塞调用的,既然有阻塞调用,就应该有非阻塞调用,那么如何使用非阻塞调用套接字呢?使用的场景又是哪里呢?
第二道是关于客户端的客户端发起connect调用之前可以调用bind函数么
欢迎你在评论区与我分享你的答案如果这篇文章帮助你理解TCP三次握手也欢迎你点击“请朋友读”把这篇文章分享给你的朋友或者同事。

View File

@@ -0,0 +1,269 @@
<audio id="audio" title="05 | 使用套接字进行读写:开始交流吧" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4b/80/4b7f310a64b309ef7f314fc98eaabb80.mp3"></audio>
你好我是盛延敏这里是网络编程实战第5讲欢迎回来。
在前面的章节中我们讲述了套接字相关的知识包括套接字的格式套接字的创建以及TCP连接的建立等。在这一讲里我来讲一下如何使用创建的套接字收发数据。
连接建立的根本目的是为了数据的收发。拿我们常用的网购场景举例子,我们在浏览商品或者购买货品的时候,并不会察觉到网络连接的存在,但是我们可以真切感觉到数据在客户端和服务器端有效的传送, 比如浏览商品时商品信息的不断刷新,购买货品时显示购买成功的消息等。
首先我们先来看一下发送数据。
## 发送数据
发送数据时常用的有三个函数分别是write、send和sendmsg。
```
ssize_t write (int socketfd, const void *buffer, size_t size)
ssize_t send (int socketfd, const void *buffer, size_t size, int flags)
ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags)
```
每个函数都是单独使用的,使用的场景略有不同:
第一个函数是常见的文件写函数如果把socketfd换成文件描述符就是普通的文件写入。
如果想指定选项发送带外数据就需要使用第二个带flag的函数。所谓带外数据是一种基于TCP协议的紧急数据用于客户端-服务器在特定场景下的紧急处理。
如果想指定多重缓冲区传输数据就需要使用第三个函数以结构体msghdr的方式发送数据。
你看到这里可能会问既然套接字描述符是一种特殊的描述符那么在套接字描述符上调用write函数应该和在普通文件描述符上调用write函数的行为是一致的都是通过描述符句柄写入指定的数据。
乍一看,两者的表现形式是一样,内在的区别还是很不一样的。
对于普通文件描述符而言一个文件描述符代表了打开的一个文件句柄通过调用write函数操作系统内核帮我们不断地往文件系统中写入字节流。注意写入的字节流大小通常和输入参数size的值是相同的否则表示出错。
对于套接字描述符而言它代表了一个双向连接在套接字描述符上调用write写入的字节数**有可能**比请求的数量少,这在普通文件描述符情况下是不正常的。
产生这个现象的原因在于操作系统内核为读取和发送数据做了很多我们表面上看不到的工作。接下来我拿write函数举例重点阐述发送缓冲区的概念。
### 发送缓冲区
你一定要建立一个概念当TCP三次握手成功TCP连接成功建立后操作系统内核会为每一个连接创建配套的基础设施比如**发送缓冲区**。
发送缓冲区的大小可以通过套接字选项来改变当我们的应用程序调用write函数时实际所做的事情是把数据**从应用程序中拷贝到操作系统内核的发送缓冲区中**,并不一定是把数据通过套接字写出去。
这里有几种情况:
第一种情况很简单操作系统内核的发送缓冲区足够大可以直接容纳这份数据那么皆大欢喜我们的程序从write调用中退出返回写入的字节数就是应用程序的数据大小。
第二种情况是,操作系统内核的发送缓冲区是够大了,不过还有数据没有发送完,或者数据发送完了,但是操作系统内核的发送缓冲区不足以容纳应用程序数据,在这种情况下,你预料的结果是什么呢?报错?还是直接返回?
操作系统内核并不会返回也不会报错而是应用程序被阻塞也就是说应用程序在write函数调用处停留不直接返回。术语“挂起”也表达了相同的意思不过“挂起”是从操作系统内核角度来说的。
那么什么时候才会返回呢?
实际上每个操作系统内核的处理是不同的。大部分UNIX系统的做法是一直等到可以把应用程序数据完全放到操作系统内核的发送缓冲区中再从系统调用中返回。怎么理解呢
别忘了我们的操作系统内核是很聪明的当TCP连接建立之后它就开始运作起来。你可以把发送缓冲区想象成一条包裹流水线有个聪明且忙碌的工人不断地从流水线上取出包裹数据这个工人会按照TCP/IP的语义将取出的包裹数据封装成TCP的MSS包以及IP的MTU包最后走数据链路层将数据发送出去。这样我们的发送缓冲区就又空了一部分于是又可以继续从应用程序搬一部分数据到发送缓冲区里这样一直进行下去到某一个时刻应用程序的数据可以完全放置到发送缓冲区里。在这个时候write阻塞调用返回。注意返回的时刻应用程序数据并没有全部被发送出去发送缓冲区里还有部分数据这部分数据会在稍后由操作系统内核通过网络发送出去。
<img src="https://static001.geekbang.org/resource/image/fd/dc/fdcdc766c6a6ebb7fbf15bb2d1e58bdc.png" alt="">
## 读取数据
我们可以注意到,套接字描述本身和本地文件描述符并无区别,**在UNIX的世界里万物都是文件**这就意味着可以将套接字描述符传递给那些原先为处理本地文件而设计的函数。这些函数包括read和write交换数据的函数。
### read函数
让我们先从最简单的read函数开始看起这个函数的原型如下
```
ssize_t read (int socketfd, void *buffer, size_t size)
```
read函数要求操作系统内核从套接字描述字socketfd**读取最多多少个字节size并将结果存储到buffer中。返回值告诉我们实际读取的字节数目也有一些特殊情况如果返回值为0表示EOFend-of-file这在网络中表示对端发送了FIN包要处理断连的情况**;如果返回值为-1表示出错。当然如果是非阻塞I/O情况会略有不同在后面的提高篇中我们会重点讲述非阻塞I/O的特点。
注意这里是最多读取size个字节。如果我们想让应用程序每次都读到size个字节就需要编写下面的函数不断地循环读取。
```
/* 从socketfd描述字中读取&quot;size&quot;个字节. */
size_t readn(int fd, void *buffer, size_t size) {
char *buffer_pointer = buffer;
int length = size;
while (length &gt; 0) {
int result = read(fd, buffer_pointer, length);
if (result &lt; 0) {
if (errno == EINTR)
continue; /* 考虑非阻塞的情况这里需要再次调用read */
else
return (-1);
} else if (result == 0)
break; /* EOF(End of File)表示套接字关闭 */
length -= result;
buffer_pointer += result;
}
return (size - length); /* 返回的是实际读取的字节数*/
}
```
对这个程序稍微解释下:
- 6-19行的循环条件表示的是在没读满size个字节之前一直都要循环下去。
- 10-11行表示的是非阻塞I/O的情况下没有数据可以读需要继续调用read。
- 14-15行表示读到对方发出的FIN包表现形式是EOF此时需要关闭套接字。
- 17-18行需要读取的字符数减少缓存指针往下移动。
- 20行是在读取EOF跳出循环后返回实际读取的字符数。
## 缓冲区实验
我们用一个客户端-服务器的例子来解释一下读取缓冲区和发送缓冲区的概念。在这个例子中客户端不断地发送数据,服务器端每读取一段数据之后进行休眠,以模拟实际业务处理所需要的时间。
### 服务器端读取数据程序
下面是服务器端读取数据的程序:
```
#include &quot;lib/common.h&quot;
void read_data(int sockfd) {
ssize_t n;
char buf[1024];
int time = 0;
for (;;) {
fprintf(stdout, &quot;block in read\n&quot;);
if ((n = readn(sockfd, buf, 1024)) == 0)
return;
time++;
fprintf(stdout, &quot;1K read for %d \n&quot;, time);
usleep(1000);
}
}
int main(int argc, char **argv) {
int listenfd, connfd;
socklen_t clilen;
struct sockaddr_in cliaddr, servaddr;
listenfd = socket(AF_INET, SOCK_STREAM, 0);
bzero(&amp;servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(12345);
/* bind到本地地址端口为12345 */
bind(listenfd, (struct sockaddr *) &amp;servaddr, sizeof(servaddr));
/* listen的backlog为1024 */
listen(listenfd, 1024);
/* 循环处理用户请求 */
for (;;) {
clilen = sizeof(cliaddr);
connfd = accept(listenfd, (struct sockaddr *) &amp;cliaddr, &amp;clilen);
read_data(connfd); /* 读取数据 */
close(connfd); /* 关闭连接套接字,注意不是监听套接字*/
}
}
```
对服务器端程序解释如下:
- 21-35行先后创建了socket套接字bind到对应地址和端口并开始调用listen接口监听
- 38-42行循环等待连接通过accept获取实际的连接并开始读取数据
- 8-15行实际每次读取1K数据之后休眠1秒用来模拟服务器端处理时延。
### 客户端发送数据程序
下面是客户端发送数据的程序:
```
#include &quot;lib/common.h&quot;
#define MESSAGE_SIZE 102400
void send_data(int sockfd) {
char *query;
query = malloc(MESSAGE_SIZE + 1);
for (int i = 0; i &lt; MESSAGE_SIZE; i++) {
query[i] = 'a';
}
query[MESSAGE_SIZE] = '\0';
const char *cp;
cp = query;
size_t remaining = strlen(query);
while (remaining) {
int n_written = send(sockfd, cp, remaining, 0);
fprintf(stdout, &quot;send into buffer %ld \n&quot;, n_written);
if (n_written &lt;= 0) {
error(1, errno, &quot;send failed&quot;);
return;
}
remaining -= n_written;
cp += n_written;
}
return;
}
int main(int argc, char **argv) {
int sockfd;
struct sockaddr_in servaddr;
if (argc != 2)
error(1, 0, &quot;usage: tcpclient &lt;IPaddress&gt;&quot;);
sockfd = socket(AF_INET, SOCK_STREAM, 0);
bzero(&amp;servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(12345);
inet_pton(AF_INET, argv[1], &amp;servaddr.sin_addr);
int connect_rt = connect(sockfd, (struct sockaddr *) &amp;servaddr, sizeof(servaddr));
if (connect_rt &lt; 0) {
error(1, errno, &quot;connect failed &quot;);
}
send_data(sockfd);
exit(0);
}
```
对客户端程序解释如下:
- 31-37行先后创建了socket套接字调用connect向对应服务器端发起连接请求
- 43行在连接建立成功后调用send_data发送数据
- 6-11行初始化了一个长度为MESSAGE_SIZE的字符串流
- 16-25行调用send函数将MESSAGE_SIZE长度的字符串流发送出去
### 实验一: 观察客户端数据发送行为
客户端程序发送了一个很大的字节流,程序运行起来之后,我们会看到服务端不断地在屏幕上打印出读取字节流的过程:
<img src="https://static001.geekbang.org/resource/image/34/1d/3455bb84f5ee020bc14bc1e15ead4d1d.jpg" alt=""><br>
而客户端直到最后所有的字节流发送完毕才打印出下面的一句话说明在此之前send函数一直都是阻塞的也就是说**阻塞式套接字最终发送返回的实际写入字节数和请求字节数是相等的。**
而关于非阻塞套接字的操作,我会在后面的文章中讲解。
### 实验二: 服务端处理变慢
如果我们把服务端的休眠时间稍微调大把客户端发送的字节数从10240000调整为1024000再次运行刚才的例子我们会发现客户端很快打印出一句话
<img src="https://static001.geekbang.org/resource/image/b5/e6/b56f01f842b2344e1480ff519d1627e6.jpg" alt=""><br>
但与此同时,服务端读取程序还在屏幕上不断打印读取数据的进度,显示出服务端读取程序还在辛苦地从缓冲区中读取数据。
通过这个例子我想再次强调一下:
**发送成功仅仅表示的是数据被拷贝到了发送缓冲区中,并不意味着连接对端已经收到所有的数据。至于什么时候发送到对端的接收缓冲区,或者更进一步说,什么时候被对方应用程序缓冲所接收,对我们而言完全都是透明的。**
## 总结
这一讲重点讲述了通过send和read来收发数据包你需要牢记以下两点
- 对于send来说返回成功仅仅表示数据写到发送缓冲区成功并不表示对端已经成功收到。
- 对于read来说需要循环读取数据并且需要考虑EOF等异常条件。
## 思考题
最后你不妨思考一下,既然缓冲区如此重要,我们可不可以把缓冲区搞得大大的,这样不就可以提高应用程序的吞吐量了么?你可以想一想这个方法可行吗?另外你可以自己总结一下,一段数据流从应用程序发送端,一直到应用程序接收端,总共经过了多少次拷贝?
欢迎你在评论区与我分享你的答案,如果你理解了套接字读写的过程,也欢迎把这篇文章分享给你的朋友或者同事。

View File

@@ -0,0 +1,366 @@
<audio id="audio" title="06 | 嗨别忘了UDP这个小兄弟" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/12/02/1264e068377b6ef9955d5d0774fb0902.mp3"></audio>
你好我是盛延敏这里是网络编程实战第6讲欢迎回来。
前面几讲我们讲述了TCP方面的编程知识这一讲我们来讲讲UDP方面的编程知识。
如果说TCP是网络协议的“大哥”那么UDP可以说是“小兄弟”。这个小兄弟和大哥比有什么差异呢
首先UDP是一种“数据报”协议而TCP是一种面向连接的“数据流”协议。
TCP可以用日常生活中打电话的场景打比方前面也多次用到了这样的例子。在这个例子中拨打号码、接通电话、开始交流分别对应了TCP的三次握手和报文传送。一旦双方的连接建立那么双方对话时一定知道彼此是谁。这个时候我们就说这种对话是有上下文的。
同样的我们也可以给UDP找一个类似的例子这个例子就是邮寄明信片。在这个例子中发信方在明信片中填上了接收方的地址和邮编投递到邮局的邮筒之后就可以不管了。发信方也可以给这个接收方再邮寄第二张、第三张甚至是第四张明信片但是这几张明信片之间是没有任何关系的他们的到达顺序也是不保证的有可能最后寄出的第四张明信片最先到达接收者的手中因为没有序号接收者也不知道这是第四张寄出的明信片而且即使接收方没有收到明信片也没有办法重新邮寄一遍该明信片。
这两个简单的例子道出了UDP和TCP之间最大的区别。
TCP是一个面向连接的协议TCP在IP报文的基础上增加了诸如重传、确认、有序传输、拥塞控制等能力通信的双方是在一个确定的上下文中工作的。
而UDP则不同UDP没有这样一个确定的上下文它是一个不可靠的通信协议没有重传和确认没有有序控制也没有拥塞控制。我们可以简单地理解为在IP报文的基础上UDP增加的能力有限。
UDP不保证报文的有效传递不保证报文的有序也就是说使用UDP的时候我们需要做好丢包、重传、报文组装等工作。
既然如此为什么我们还要使用UDP协议呢
答案很简单因为UDP比较简单适合的场景还是比较多的我们常见的DNS服务SNMP服务都是基于UDP协议的这些场景对时延、丢包都不是特别敏感。另外多人通信的场景如聊天室、多人游戏等也都会使用到UDP协议。
## UDP编程
UDP和TCP编程非常不同下面这张图是UDP程序设计时的主要过程。
<img src="https://static001.geekbang.org/resource/image/84/30/8416f0055bedce10a3c7d0416cc1f430.png" alt=""><br>
我们看到服务器端创建UDP 套接字之后绑定到本地端口调用recvfrom函数等待客户端的报文发送客户端创建套接字之后调用sendto函数往目标地址和端口发送UDP报文然后客户端和服务器端进入互相应答过程。
recvfrom和sendto是UDP用来接收和发送报文的两个主要函数
```
#include &lt;sys/socket.h&gt;
ssize_t recvfrom(int sockfd, void *buff, size_t nbytes, int flags,
          struct sockaddr *from, socklen_t *addrlen);
ssize_t sendto(int sockfd, const void *buff, size_t nbytes, int flags,
const struct sockaddr *to, socklen_t addrlen);
```
我们先来看一下recvfrom函数。
sockfd、buff和nbytes是前三个参数。sockfd是本地创建的套接字描述符buff指向本地的缓存nbytes表示最大接收数据字节。
第四个参数flags是和I/O相关的参数这里我们还用不到设置为0。
后面两个参数from和addrlen实际上是返回对端发送方的地址和端口等信息这和TCP非常不一样TCP是通过accept函数拿到的描述字信息来决定对端的信息。另外UDP报文每次接收都会获取对端的信息也就是说报文和报文之间是没有上下文的。
函数的返回值告诉我们实际接收的字节数。
接下来看一下sendto函数。
sendto函数中的前三个参数为sockfd、buff和nbytes。sockfd是本地创建的套接字描述符buff指向发送的缓存nbytes表示发送字节数。第四个参数flags依旧设置为0。
后面两个参数to和addrlen表示发送的对端地址和端口等信息。
函数的返回值告诉我们实际发送的字节数。
我们知道, TCP的发送和接收每次都是在一个上下文中类似这样的过程
A连接上: 接收→发送→接收→发送→…
B连接上: 接收→发送→接收→发送→ …
而UDP的每次接收和发送都是一个独立的上下文类似这样
接收A→发送A→接收B→发送B →接收C→发送C→ …
## UDP服务端例子
我们先来看一个UDP服务器端的例子
```
#include &quot;lib/common.h&quot;
static int count;
static void recvfrom_int(int signo) {
printf(&quot;\nreceived %d datagrams\n&quot;, 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(&amp;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 *) &amp;server_addr, sizeof(server_addr));
socklen_t client_len;
char message[MAXLINE];
count = 0;
signal(SIGINT, recvfrom_int);
struct sockaddr_in client_addr;
client_len = sizeof(client_addr);
for (;;) {
int n = recvfrom(socket_fd, message, MAXLINE, 0, (struct sockaddr *) &amp;client_addr, &amp;client_len);
message[n] = 0;
printf(&quot;received %d bytes: %s\n&quot;, n, message);
char send_line[MAXLINE];
sprintf(send_line, &quot;Hi, %s&quot;, message);
sendto(socket_fd, send_line, strlen(send_line), 0, (struct sockaddr *) &amp;client_addr, client_len);
count++;
}
}
```
程序的1213行首先创建一个套接字注意这里的套接字类型是“SOCK_DGRAM”表示的是UDP数据报。
1521行和TCP服务器端类似绑定数据报套接字到本地的一个端口上。
27行为该服务器创建了一个信号处理函数以便在响应“Ctrl+C”退出时打印出收到的报文总数。
3142行是该服务器端的主体通过调用recvfrom函数获取客户端发送的报文之后我们对收到的报文进行重新改造加上“Hi”的前缀再通过sendto函数发送给客户端对端。
## UDP客户端例子
接下来我们再来构建一个对应的UDP客户端。在这个例子中从标准输入中读取输入的字符串后发送给服务端并且把服务端经过处理的报文打印到标准输出上。
```
#include &quot;lib/common.h&quot;
# define MAXLINE 4096
int main(int argc, char **argv) {
if (argc != 2) {
error(1, 0, &quot;usage: udpclient &lt;IPaddress&gt;&quot;);
}
int socket_fd;
socket_fd = socket(AF_INET, SOCK_DGRAM, 0);
struct sockaddr_in server_addr;
bzero(&amp;server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(SERV_PORT);
inet_pton(AF_INET, argv[1], &amp;server_addr.sin_addr);
socklen_t server_len = sizeof(server_addr);
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(&quot;now sending %s\n&quot;, send_line);
size_t rt = sendto(socket_fd, send_line, strlen(send_line), 0, (struct sockaddr *) &amp;server_addr, server_len);
if (rt &lt; 0) {
error(1, errno, &quot;send failed &quot;);
}
printf(&quot;send bytes: %zu \n&quot;, rt);
len = 0;
n = recvfrom(socket_fd, recv_line, MAXLINE, 0, reply_addr, &amp;len);
if (n &lt; 0)
error(1, errno, &quot;recvfrom failed&quot;);
recv_line[n] = 0;
fputs(recv_line, stdout);
fputs(&quot;\n&quot;, stdout);
}
exit(0);
}
```
1011行创建一个类型为“SOCK_DGRAM”的套接字。
1317行初始化目标服务器的地址和端口。
2851行为程序主体从标准输入中读取的字符进行处理后调用sendto函数发送给目标服务器端然后再次调用recvfrom函数接收目标服务器发送过来的新报文并将其打印到标准输出上。
为了让你更好地理解UDP和TCP之间的差别我们模拟一下UDP的三种运行场景你不妨思考一下这三种场景的结果和TCP的到底有什么不同
## 场景一:只运行客户端
如果我们只运行客户端程序会一直阻塞在recvfrom上。
```
$ ./udpclient 127.0.0.1
1
now sending g1
send bytes: 2
&lt;阻塞在这里&gt;
```
还记得TCP程序吗如果不开启服务端TCP客户端的connect函数会直接返回“Connection refused”报错信息。而在UDP程序里则会一直阻塞在这里。
## 场景二:先开启服务端,再开启客户端
在这个场景里,我们先开启服务端在端口侦听,然后再开启客户端:
```
$./udpserver
received 2 bytes: g1
received 2 bytes: g2
```
```
$./udpclient 127.0.0.1
g1
now sending g1
send bytes: 2
Hi, g1
g2
now sending g2
send bytes: 2
Hi, g2
```
我们在客户端一次输入g1、g2服务器端在屏幕上打印出收到的字符并且可以看到我们的客户端也收到了服务端的回应“Hi,g1”和“Hi,g2”。
## 场景三: 开启服务端,再一次开启两个客户端
这个实验中,在服务端开启之后,依次开启两个客户端,并发送报文。
服务端:
```
$./udpserver
received 2 bytes: g1
received 2 bytes: g2
received 2 bytes: g3
received 2 bytes: g4
```
第一个客户端:
```
$./udpclient 127.0.0.1
now sending g1
send bytes: 2
Hi, g1
g3
now sending g3
send bytes: 2
Hi, g3
```
第二个客户端:
```
$./udpclient 127.0.0.1
now sending g2
send bytes: 2
Hi, g2
g4
now sending g4
send bytes: 2
Hi, g4
```
我们看到,两个客户端发送的报文,依次都被服务端收到,并且客户端也可以收到服务端处理之后的报文。
如果我们此时把服务器端进程杀死,就可以看到信号函数在进程退出之前,打印出服务器端接收到的报文个数。
```
$ ./udpserver
received 2 bytes: g1
received 2 bytes: g2
received 2 bytes: g3
received 2 bytes: g4
^C
received 4 datagrams
```
之后我们再重启服务器端进程并使用客户端1和客户端2继续发送新的报文我们可以看到和TCP非常不同的结果。
以下就是服务器端的输出服务器端重启后可以继续收到客户端的报文这在TCP里是不可以的TCP断联之后必须重新连接才可以发送报文信息。但是UDP报文的“无连接”的特点可以在UDP服务器重启之后继续进行报文的发送这就是UDP报文“无上下文”的最好说明。
```
$ ./udpserver
received 2 bytes: g1
received 2 bytes: g2
received 2 bytes: g3
received 2 bytes: g4
^C
received 4 datagrams
$ ./udpserver
received 2 bytes: g5
received 2 bytes: g6
```
第一个客户端:
```
$./udpclient 127.0.0.1
now sending g1
send bytes: 2
Hi, g1
g3
now sending g3
send bytes: 2
Hi, g3
g5
now sending g5
send bytes: 2
Hi, g5
```
第二个客户端:
```
$./udpclient 127.0.0.1
now sending g2
send bytes: 2
Hi, g2
g4
now sending g4
send bytes: 2
Hi, g4
g6
now sending g6
send bytes: 2
Hi, g6
```
## 总结
在这一讲里我介绍了UDP程序的例子我们需要重点关注以下两点
- UDP是无连接的数据报程序和TCP不同不需要三次握手建立一条连接。
- UDP程序通过recvfrom和sendto函数直接收发数据报报文。
## 思考题
最后给你留两个思考题吧。在第一个场景中recvfrom一直处于阻塞状态中这是非常不合理的你觉得这种情形应该怎么处理呢另外既然UDP是请求-应答模式的那么请求中的UDP报文最大可以是多大呢
欢迎你在评论区写下你的思考我会和你一起讨论。也欢迎把这篇文章分享给你的朋友或者同事一起讨论一下UDP这个协议。

View File

@@ -0,0 +1,424 @@
<audio id="audio" title="07 | What? 还有本地套接字?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/07/fc/0732f7b5f140e400a6742884e65fadfc.mp3"></audio>
你好我是盛延敏这里是网络编程实战第7讲欢迎回来。
上一篇文章中我们讲了UDP。很多同学都知道TCP和UDP但是对本地套接字却不甚了解。
实际上本地套接字是IPC也就是本地进程间通信的一种实现方式。除了本地套接字以外其它技术诸如管道、共享消息队列等也是进程间通信的常用方法但因为本地套接字开发便捷接受度高所以普遍适用于在同一台主机上进程间通信的各种场景。
那么今天我们就来学习下本地套接字方面的知识,并且利用本地套接字完成可靠字节流和数据报两种协议。
## 从例子开始
现在最火的云计算技术是什么无疑是Kubernetes和Docker。在Kubernetes和Docker的技术体系中有很多优秀的设计比如Kubernetes的CRIContainer Runtime Interface其思想是将Kubernetes的主要逻辑和Container Runtime的实现解耦。
我们可以通过netstat命令查看Linux系统内的本地套接字状况下面这张图列出了路径为/var/run/dockershim.socket的stream类型的本地套接字可以清楚地看到开启这个套接字的进程为kubelet。kubelet是Kubernetes的一个组件这个组件负责将控制器和调度器的命令转化为单机上的容器实例。为了实现和容器运行时的解耦kubelet设计了基于本地套接字的客户端-服务器GRPC调用。
<img src="https://static001.geekbang.org/resource/image/c7/6b/c75a8467a84f30e523917f28f2f4266b.jpg" alt=""><br>
眼尖的同学可能发现列表里还有docker-containerd.sock等其他本地套接字是的Docker其实也是大量使用了本地套接字技术来构建的。
如果我们在/var/run目录下将会看到docker使用的本地套接字描述符:
<img src="https://static001.geekbang.org/resource/image/a0/4d/a0e6f8ca0f9c5727f554323a26a9c14d.jpg" alt="">
## 本地套接字概述
本地套接字一般也叫做UNIX域套接字最新的规范已经改叫本地套接字。在前面的TCP/UDP例子中我们经常使用127.0.0.1完成客户端进程和服务器端进程同时在本机上的通信,那么,这里的本地套接字又是什么呢?
本地套接字是一种特殊类型的套接字和TCP/UDP套接字不同。TCP/UDP即使在本地地址通信也要走系统网络协议栈而本地套接字严格意义上说提供了一种单主机跨进程间调用的手段减少了协议栈实现的复杂度效率比TCP/UDP套接字都要高许多。类似的IPC机制还有UNIX管道、共享内存和RPC调用等。
比如X Window实现如果发现是本地连接就会走本地套接字工作效率非常高。
现在你可以回忆一下,在前面介绍套接字地址时,我们讲到了本地地址,这个本地地址就是本地套接字专属的。
<img src="https://static001.geekbang.org/resource/image/ed/58/ed49b0f1b658e82cb07a6e1e81f36b58.png" alt="">
## 本地字节流套接字
我们先从字节流本地套接字开始。
这是一个字节流类型的本地套接字服务器端例子。在这个例子中,服务器程序打开本地套接字后,接收客户端发送来的字节流,并往客户端回送了新的字节流。
```
#include &quot;lib/common.h&quot;
int main(int argc, char **argv) {
if (argc != 2) {
error(1, 0, &quot;usage: unixstreamserver &lt;local_path&gt;&quot;);
}
int listenfd, connfd;
socklen_t clilen;
struct sockaddr_un cliaddr, servaddr;
listenfd = socket(AF_LOCAL, SOCK_STREAM, 0);
if (listenfd &lt; 0) {
error(1, errno, &quot;socket create failed&quot;);
}
char *local_path = argv[1];
unlink(local_path);
bzero(&amp;servaddr, sizeof(servaddr));
servaddr.sun_family = AF_LOCAL;
strcpy(servaddr.sun_path, local_path);
if (bind(listenfd, (struct sockaddr *) &amp;servaddr, sizeof(servaddr)) &lt; 0) {
error(1, errno, &quot;bind failed&quot;);
}
if (listen(listenfd, LISTENQ) &lt; 0) {
error(1, errno, &quot;listen failed&quot;);
}
clilen = sizeof(cliaddr);
if ((connfd = accept(listenfd, (struct sockaddr *) &amp;cliaddr, &amp;clilen)) &lt; 0) {
if (errno == EINTR)
error(1, errno, &quot;accept failed&quot;); /* back to for() */
else
error(1, errno, &quot;accept failed&quot;);
}
char buf[BUFFER_SIZE];
while (1) {
bzero(buf, sizeof(buf));
if (read(connfd, buf, BUFFER_SIZE) == 0) {
printf(&quot;client quit&quot;);
break;
}
printf(&quot;Receive: %s&quot;, buf);
char send_line[MAXLINE];
sprintf(send_line, &quot;Hi, %s&quot;, buf);
int nbytes = sizeof(send_line);
if (write(connfd, send_line, nbytes) != nbytes)
error(1, errno, &quot;write error&quot;);
}
close(listenfd);
close(connfd);
exit(0);
}
```
我对这个程序做一个详细的解释:
- 第1215行非常关键**这里创建的套接字类型注意是AF_LOCAL并且使用字节流格式**。你现在可以回忆一下TCP的类型是AF_INET和字节流类型UDP的类型是AF_INET和数据报类型。在前面的文章中我们提到AF_UNIX也是可以的基本上可以认为和AF_LOCAL是等价的。
- 第1721行创建了一个本地地址这里的本地地址和IPv4、IPv6地址可以对应数据类型为sockaddr_un这个数据类型中的sun_family需要填写为AF_LOCAL最为关键的是需要对sun_path设置一个本地文件路径。我们这里还做了一个unlink操作以便把存在的文件删除掉这样可以保持幂等性。
- 第2329行分别执行bind和listen操作这样就监听在一个本地文件路径标识的套接字上这和普通的TCP服务端程序没什么区别。
- 第4156行使用read和write函数从套接字中按照字节流的方式读取和发送数据。
我在这里着重强调一下本地文件路径。关于本地文件路径,需要明确一点,它必须是“绝对路径”,这样的话,编写好的程序可以在任何目录里被启动和管理。如果是“相对路径”,为了保持同样的目的,这个程序的启动路径就必须固定,这样一来,对程序的管理反而是一个很大的负担。
另外还要明确一点这个本地文件必须是一个“文件”不能是一个“目录”。如果文件不存在后面bind操作时会自动创建这个文件。
还有一点需要牢记在Linux下任何文件操作都有权限的概念应用程序启动时也有应用属主。如果当前启动程序的用户权限不能创建文件你猜猜会发生什么呢这里我先卖个关子一会演示的时候你就会看到结果。
下面我们再看一下客户端程序。
```
#include &quot;lib/common.h&quot;
int main(int argc, char **argv) {
if (argc != 2) {
error(1, 0, &quot;usage: unixstreamclient &lt;local_path&gt;&quot;);
}
int sockfd;
struct sockaddr_un servaddr;
sockfd = socket(AF_LOCAL, SOCK_STREAM, 0);
if (sockfd &lt; 0) {
error(1, errno, &quot;create socket failed&quot;);
}
bzero(&amp;servaddr, sizeof(servaddr));
servaddr.sun_family = AF_LOCAL;
strcpy(servaddr.sun_path, argv[1]);
if (connect(sockfd, (struct sockaddr *) &amp;servaddr, sizeof(servaddr)) &lt; 0) {
error(1, errno, &quot;connect failed&quot;);
}
char send_line[MAXLINE];
bzero(send_line, MAXLINE);
char recv_line[MAXLINE];
while (fgets(send_line, MAXLINE, stdin) != NULL) {
int nbytes = sizeof(send_line);
if (write(sockfd, send_line, nbytes) != nbytes)
error(1, errno, &quot;write error&quot;);
if (read(sockfd, recv_line, MAXLINE) == 0)
error(1, errno, &quot;server terminated prematurely&quot;);
fputs(recv_line, stdout);
}
exit(0);
}
```
下面我带大家理解一下这个客户端程序。
- 1114行创建了一个本地套接字和前面服务器端程序一样用的也是字节流类型SOCK_STREAM。
- 1618行初始化目标服务器端的地址。我们知道在TCP编程中使用的是服务器的IP地址和端口作为目标在本地套接字中则使用文件路径作为目标标识sun_path这个字段标识的是目标文件路径所以这里需要对sun_path进行初始化。
- 20行和TCP客户端一样发起对目标套接字的connect调用不过由于是本地套接字并不会有三次握手。
- 2838行从标准输入中读取字符串向服务器端发送之后将服务器端传输过来的字符打印到标准输出上。
总体上我们可以看到本地字节流套接字和TCP服务器端、客户端编程最大的差异就是套接字类型的不同。本地字节流套接字识别服务器不再通过IP地址和端口而是通过本地文件。
接下来,我们就运行这个程序来加深对此的理解。
### 只启动客户端
第一个场景中,我们只启动客户端程序:
```
$ ./unixstreamclient /tmp/unixstream.sock
connect failed: No such file or directory (2)
```
我们看到,由于没有启动服务器端,没有一个本地套接字在/tmp/unixstream.sock这个文件上监听客户端直接报错提示我们没有文件存在。
### 服务器端监听在无权限的文件路径上
还记得我们在前面卖的关子吗在Linux下执行任何应用程序都有应用属主的概念。在这里我们让服务器端程序的应用属主没有/var/lib/目录的权限,然后试着启动一下这个服务器程序
```
$ ./unixstreamserver /var/lib/unixstream.sock
bind failed: Permission denied (13)
```
这个结果告诉我们启动服务器端程序的用户,必须对本地监听路径有权限。这个结果和你期望的一致吗?
试一下root用户启动该程序
```
sudo ./unixstreamserver /var/lib/unixstream.sock
(阻塞运行中)
```
我们看到,服务器端程序正常运行了。
打开另外一个shell我们看到/var/lib下创建了一个本地文件大小为0而且文件的最后结尾有一个=号。其实这就是bind的时候自动创建出来的文件。
```
$ ls -al /var/lib/unixstream.sock
rwxr-xr-x 1 root root 0 Jul 15 12:41 /var/lib/unixstream.sock=
```
如果我们使用netstat命令查看UNIX域套接字就会发现unixstreamserver这个进程监听在/var/lib/unixstream.sock这个文件路径上。
<img src="https://static001.geekbang.org/resource/image/58/b1/58d259d15b7012645d168a9c5d9f3fb1.jpg" alt=""><br>
看看很简单吧我们写的程序和鼎鼎大名的Kubernetes运行在同一机器上原理和行为完全一致。
### 服务器-客户端应答
现在,我们让服务器和客户端都正常启动,并且客户端依次发送字符:
```
$./unixstreamserver /tmp/unixstream.sock
Receive: g1
Receive: g2
Receive: g3
client quit
```
```
$./unixstreamclient /tmp/unixstream.sock
g1
Hi, g1
g2
Hi, g2
g3
Hi, g3
^C
```
我们可以看到服务器端陆续收到客户端发送的字节同时客户端也收到了服务器端的应答最后当我们使用Ctrl+C让客户端程序退出时服务器端也正常退出。
## 本地数据报套接字
我们再来看下在本地套接字上使用数据报的服务器端例子:
```
#include &quot;lib/common.h&quot;
int main(int argc, char **argv) {
if (argc != 2) {
error(1, 0, &quot;usage: unixdataserver &lt;local_path&gt;&quot;);
}
int socket_fd;
socket_fd = socket(AF_LOCAL, SOCK_DGRAM, 0);
if (socket_fd &lt; 0) {
error(1, errno, &quot;socket create failed&quot;);
}
struct sockaddr_un servaddr;
char *local_path = argv[1];
unlink(local_path);
bzero(&amp;servaddr, sizeof(servaddr));
servaddr.sun_family = AF_LOCAL;
strcpy(servaddr.sun_path, local_path);
if (bind(socket_fd, (struct sockaddr *) &amp;servaddr, sizeof(servaddr)) &lt; 0) {
error(1, errno, &quot;bind failed&quot;);
}
char buf[BUFFER_SIZE];
struct sockaddr_un client_addr;
socklen_t client_len = sizeof(client_addr);
while (1) {
bzero(buf, sizeof(buf));
if (recvfrom(socket_fd, buf, BUFFER_SIZE, 0, (struct sockadd *) &amp;client_addr, &amp;client_len) == 0) {
printf(&quot;client quit&quot;);
break;
}
printf(&quot;Receive: %s \n&quot;, buf);
char send_line[MAXLINE];
bzero(send_line, MAXLINE);
sprintf(send_line, &quot;Hi, %s&quot;, buf);
size_t nbytes = strlen(send_line);
printf(&quot;now sending: %s \n&quot;, send_line);
if (sendto(socket_fd, send_line, nbytes, 0, (struct sockadd *) &amp;client_addr, client_len) != nbytes)
error(1, errno, &quot;sendto error&quot;);
}
close(socket_fd);
exit(0);
}
```
本地数据报套接字和前面的字节流本地套接字有以下几点不同:
- 第9行创建的本地套接字**这里创建的套接字类型注意是AF_LOCAL**协议类型为SOCK_DGRAM。
- 2123行bind到本地地址之后没有再调用listen和accept回忆一下这其实和UDP的性质一样。
- 2845行使用recvfrom和sendto来进行数据报的收发不再是read和send这其实也和UDP网络程序一致。
然后我们再看一下客户端的例子:
```
#include &quot;lib/common.h&quot;
int main(int argc, char **argv) {
if (argc != 2) {
error(1, 0, &quot;usage: unixdataclient &lt;local_path&gt;&quot;);
}
int sockfd;
struct sockaddr_un client_addr, server_addr;
sockfd = socket(AF_LOCAL, SOCK_DGRAM, 0);
if (sockfd &lt; 0) {
error(1, errno, &quot;create socket failed&quot;);
}
bzero(&amp;client_addr, sizeof(client_addr)); /* bind an address for us */
client_addr.sun_family = AF_LOCAL;
strcpy(client_addr.sun_path, tmpnam(NULL));
if (bind(sockfd, (struct sockaddr *) &amp;client_addr, sizeof(client_addr)) &lt; 0) {
error(1, errno, &quot;bind failed&quot;);
}
bzero(&amp;server_addr, sizeof(server_addr));
server_addr.sun_family = AF_LOCAL;
strcpy(server_addr.sun_path, argv[1]);
char send_line[MAXLINE];
bzero(send_line, MAXLINE);
char recv_line[MAXLINE];
while (fgets(send_line, MAXLINE, stdin) != NULL) {
int i = strlen(send_line);
if (send_line[i - 1] == '\n') {
send_line[i - 1] = 0;
}
size_t nbytes = strlen(send_line);
printf(&quot;now sending %s \n&quot;, send_line);
if (sendto(sockfd, send_line, nbytes, 0, (struct sockaddr *) &amp;server_addr, sizeof(server_addr)) != nbytes)
error(1, errno, &quot;sendto error&quot;);
int n = recvfrom(sockfd, recv_line, MAXLINE, 0, NULL, NULL);
recv_line[n] = 0;
fputs(recv_line, stdout);
fputs(&quot;\n&quot;, stdout);
}
exit(0);
}
```
这个程序和UDP网络编程的例子基本是一致的我们可以把它当作是用本地文件替换了IP地址和端口的UDP程序不过这里还是有一个非常大的不同的。
这个不同点就在1622行。你可以看到1622行将本地套接字bind到本地一个路径上然而UDP客户端程序是不需要这么做的。本地数据报套接字这么做的原因是它需要指定一个本地路径以便在服务器端回包时可以正确地找到地址而在UDP客户端程序里数据是可以通过UDP包的本地地址和端口来匹配的。
下面这段代码就展示了服务器端和客户端通过数据报应答的场景:
```
./unixdataserver /tmp/unixdata.sock
Receive: g1
now sending: Hi, g1
Receive: g2
now sending: Hi, g2
Receive: g3
now sending: Hi, g3
```
```
$ ./unixdataclient /tmp/unixdata.sock
g1
now sending g1
Hi, g1
g2
now sending g2
Hi, g2
g3
now sending g3
Hi, g3
^C
```
我们可以看到,服务器端陆续收到客户端发送的数据报,同时,客户端也收到了服务器端的应答。
## 总结
我在开头已经说过,本地套接字作为常用的进程间通信技术,被用于各种适用于在同一台主机上进程间通信的场景。关于本地套接字,我们需要牢记以下两点:
- 本地套接字的编程接口和IPv4、IPv6套接字编程接口是一致的可以支持字节流和数据报两种协议。
- 本地套接字的实现效率大大高于IPv4和IPv6的字节流、数据报套接字实现。
## 思考题
讲完本地套接字之后,我给你留几道思考题。
1. 在本地套接字字节流类型的客户端-服务器例子中我们让服务器端以root账号启动监听在/var/lib/unixstream.sock这个文件上。如果我们让客户端以普通用户权限启动客户端可以连接上/var/lib/unixstream.sock吗为什么呢
1. 我们看到客户端被杀死后,服务器端也正常退出了。看下退出后打印的日志,你不妨判断一下引起服务器端正常退出的逻辑是什么?
1. 你有没有想过这样一个奇怪的场景如果自己不小心写错了代码本地套接字服务器端是SOCK_DGRAM客户端使用的是SOCK_STREAM路径和其他都是正确的你觉得会发生什么呢
欢迎你在评论区写下你的思考,我会和你一起交流这些问题。如果这篇文章帮你弄懂了本地套接字,不妨把它分享给你的朋友或者同事,一起交流一下它吧!

View File

@@ -0,0 +1,273 @@
<audio id="audio" title="08 | 工欲善其事必先利其器:学会使用各种工具" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/43/07/439fc648abb50ffd027d850573845f07.mp3"></audio>
你好我是盛延敏这里是网络编程实战第8讲欢迎回来。
上一讲我们讲到了本地套接字加上前面介绍的TCP、UDP套接字你会发现我们已经比较全面地接触了套接字。
其实在平常使用套接字开发和测试过程中我们总会碰到这样或那样的问题。学会对这些问题进行诊断和分析其实需要不断地积累经验。而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序列号排序显示的一并显示的也包括TTLtime to live反映了两个IP地址之间传输的时间。最后还显示了ping命令的统计信息如最小时间、平均时间等。
我们需要经常和Linux下的ping命令打交道那么ping命令的原理到底是什么呢它是基于TCP还是UDP开发的
都不是。
其实ping是基于一种叫做ICMP的协议开发的ICMP又是一种基于IP协议的控制协议翻译为网际控制协议其报文格式如下图
<img src="https://static001.geekbang.org/resource/image/15/38/1555df944c00bdba5c2a4ea3c55cf338.png" alt=""><br>
ICMP在IP报文后加入了新的内容这些内容包括
- 类型即ICMP的类型, 其中ping的请求类型为8应答为0。
- 代码进一步划分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
```
可能的结果为:
<img src="https://static001.geekbang.org/resource/image/34/df/34084af982a4c4223e0a78ed01c662df.jpg" alt=""><br>
netstat会把所有IPv4形态的TCPIPV6形态的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
```
<img src="https://static001.geekbang.org/resource/image/a1/13/a1aeca1245b6b8cabaa0f22ce02d4813.jpg" alt=""><br>
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打开了这个本地文件套接字
<img src="https://static001.geekbang.org/resource/image/ac/28/acebeb7d0bbe26b469a200456c299d28.jpg" alt=""><br>
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]&amp;2 != 0'
```
这里tcp[13:1]表示的是TCP头部开始处偏移为13的字节如果这个值为2说明设置了SYN分节当然我们也可以设置成其他值来获取希望类型的分节。注意这里的偏移是从0开始算起的tcp[13]其实是报文里的第14个字节。
tcpdump在开启抓包的时候会自动创建一个类型为AF_PACKET的网络套接口并向系统内核注册。当网卡接收到一个网络报文之后它会遍历系统中所有已经被注册的网络协议包括其中已经注册了的AF_PACKET网络协议。系统内核接下来就会将网卡收到的报文发送给该协议的回调函数进行一次处理回调函数可以把接收到的报文完完整整地复制一份假装是自己接收到的报文然后交给tcpdump程序进行各种条件的过滤和判断再对报文进行解析输出。
下面这张图显示的是tcpdump的输出格式
<img src="https://static001.geekbang.org/resource/image/43/c3/43a9e4ea08bc872c2646453ce06ed3c3.jpg" alt=""><br>
首先我们看到的是时间戳之后类似192.168.33.11.41388 &gt; 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可以对各种奇怪的环境进行抓包进而帮我们了解报文排查问题。
## 思考题
最后给大家留两个思考题。
本章我讲到了强大的抓包工具tcpdump你知道tcpdump这个工具还可以对UDP包进行抓包处理吗你不妨尝试一下。
另外netstat输出时监听状态的套接字所对应的Foreign Address显示的*.*表示的是什么意思呢?
欢迎你在评论区写下你的思考,我会和你一起交流,也欢迎你把这篇文章分享给你的朋友或者同事,一起讨论下这几个工具。

View File

@@ -0,0 +1,218 @@
<audio id="audio" title="09丨答疑篇学习网络编程前需要准备哪些东西" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/45/2f/45e5cbc5292b5391f39846f45ed7ed2f.mp3"></audio>
你好我是盛延敏这里是网络编程实战第9讲欢迎回来。
今天是基础篇的最后一讲。在这一讲中,我将会针对基础篇中大家提出的普遍问题进行总结和答疑,让我们整理一下,再接着学习下一个模块的内容。
## 代码和环境
既然我希望通过学习,可以带你进行网络编程实战,那么就得有一个环境,可以运行文章中的例子,并加以活学活用。
我已经将代码上传到GitHub中你可以访问以下地址来获得最新的代码。
[https://github.com/froghui/yolanda](https://github.com/froghui/yolanda)
代码按照章节组织比如chap-7就对应第七篇文章。
代码按照CMake组织CMake是一个跨平台的编译管理系统使用CMake可以方便地在Linux等类UNIX系统下动态生成Makefile再由make工具编译、链接生成二进制文件。当然CMake也可以支持Windows系统下的C/C++编译,这里我们就不展开了。
所有的代码我都已经测试过可以运行在Linux和MacOS上。
### Ubuntu系统
在Linux下如果你是Ubuntu系统需要安装Cmake、make和gcc/g++等编译系统和工具。
```
sudo apt-get install gcc g++ make cmake
```
如果是CentOS或Red Hat需要执行yum install命令
```
sudo yum install gcc g++ make cmake
```
使用CMake编译程序需要两步第一步执行Cmake生成配置文件主要是Makefile具体做法是执行如下的cmake命令之后在build目录下会发现CMake根据系统环境如编译器、头文件等自动生成了一份Makefile
```
cd build &amp;&amp; cmake -f ../
```
接下来执行第二步在build目录运行make让make驱动gcc编译、链接生成二进制可执行程序这个过程可能会持续几分钟。最后在build/bin目录下会生成所有可运行的二进制程序。
```
-rwxr-xr-x 1 vagrant vagrant 13944 Aug 18 13:45 addressused*
-rwxr-xr-x 1 vagrant vagrant 14000 Aug 18 13:45 addressused02*
-rwxr-xr-x 1 vagrant vagrant 13848 Aug 18 13:45 batchwrite*
-rwxr-xr-x 1 vagrant vagrant 13800 Aug 18 13:45 bufferclient*
-rwxr-xr-x 1 vagrant vagrant 14192 Aug 18 13:45 graceclient*
-rwxr-xr-x 1 vagrant vagrant 14096 Aug 18 13:45 graceserver*
-rwxr-xr-x 1 vagrant vagrant 8960 Aug 18 13:45 make_socket*
-rwxr-xr-x 1 vagrant vagrant 13920 Aug 18 13:45 pingclient*
-rwxr-xr-x 1 vagrant vagrant 14176 Aug 18 13:45 pingserver*
-rwxr-xr-x 1 vagrant vagrant 13976 Aug 18 13:45 reliable_client01*
-rwxr-xr-x 1 vagrant vagrant 13832 Aug 18 13:45 reliable_client02*
-rwxr-xr-x 1 vagrant vagrant 14120 Aug 18 13:45 reliable_server01*
-rwxr-xr-x 1 vagrant vagrant 14040 Aug 18 13:45 reliable_server02*
-rwxr-xr-x 1 vagrant vagrant 14136 Aug 18 13:45 samplebuffer01*
-rwxr-xr-x 1 vagrant vagrant 13864 Aug 18 13:45 samplebuffer02*
-rwxr-xr-x 1 vagrant vagrant 14392 Aug 18 13:45 samplebuffer03*
-rwxr-xr-x 1 vagrant vagrant 13848 Aug 18 13:45 streamclient*
-rwxr-xr-x 1 vagrant vagrant 14392 Aug 18 13:45 streamserver*
-rwxr-xr-x 1 vagrant vagrant 13784 Aug 18 13:45 tcpclient*
-rwxr-xr-x 1 vagrant vagrant 13856 Aug 18 13:45 tcpserver*
-rwxr-xr-x 1 vagrant vagrant 13936 Aug 18 13:45 udpclient*
-rwxr-xr-x 1 vagrant vagrant 13320 Aug 18 13:45 udpserver*
-rwxr-xr-x 1 vagrant vagrant 13936 Aug 18 13:45 unixdataclient*
-rwxr-xr-x 1 vagrant vagrant 13896 Aug 18 13:45 unixdataserver*
-rwxr-xr-x 1 vagrant vagrant 13800 Aug 18 13:45 unixstreamclient*
-rwxr-xr-x 1 vagrant vagrant 13992 Aug 18 13:45 unixstreamserver*
```
### MacOS
在MacOS上Cmake和make都会有Mac特定版本并且实现的原理也是基本一致的我们可以像上面Ubuntu系统一样手动安装、配置这些工具。
如果你的系统上没有这两个软件可以使用brew安装Cmake和make。
```
brew install cmake
brew install make
```
MacOS上C/C++语言的编译器不同于GNU-GCC是一个叫做Clang的东西。Clang 背后的技术叫做LLVMLow Level Virtual Machine。LLVM 是以 BSD License开发的开源编译器框架系统基于 C++ 编写而成不仅可以支持C/C++还可以支持Swift、Rust等语言。
如果你在MaxOS上查看Clang的版本信息可以很明显地看到Clang是基于LLVM开发的并且对应的版本是多少。在我的机器上显示的LLVM版本是 10.0.0。
```
clang -v
Apple LLVM version 10.0.0 (clang-1000.10.44.4)
Target: x86_64-apple-darwin17.7.0
Thread model: posix
InstalledDir: /Library/Developer/CommandLineTools/usr/bin
```
下面是在MacOS上执行Cmake和make使用Clang完成编译和链接的过程。
```
cd build &amp;&amp; cmake -f ../
-- The C compiler identification is AppleClang 10.0.0.10001044
-- The CXX compiler identification is AppleClang 10.0.0.10001044
-- Check for working C compiler: /Library/Developer/CommandLineTools/usr/bin/cc
-- Check for working C compiler: /Library/Developer/CommandLineTools/usr/bin/cc -- works
-- Detecting C compiler ABI info
-- Detecting C compiler ABI info - done
-- Detecting C compile features
-- Detecting C compile features - done
-- Check for working CXX compiler: /Library/Developer/CommandLineTools/usr/bin/c++
-- Check for working CXX compiler: /Library/Developer/CommandLineTools/usr/bin/c++ -- works
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Configuring done
-- Generating done
-- Build files have been written to: /Users/shengym/Code/network/yolanda/test
```
```
cd build &amp;&amp; make
```
可执行程序仍然保存在 build/bin目录下面。
### CLion
对于有IDE情结的同学来说推荐使用JetBrains公司出品的CLion进行编译和调试。
你可以在这里下载 [https://www.jetbrains.com/clion/](https://www.jetbrains.com/clion/) 获得30天的免费使用。
CLion自带了CMake等工具可以开箱即用。它最强大的地方是可以直接设置断点方便进行调试。我们进入主菜单选择Run再选择Debug就可以启动程序进行调试。
<img src="https://static001.geekbang.org/resource/image/28/25/28c40e2422c01340a8ab8b368355f825.png" alt=""><br>
有些情况下启动程序时需要输入一些参数这个时候需要使用“Edit Configurations”为可执行程序配置参数。下面是一个例子
<img src="https://static001.geekbang.org/resource/image/61/5c/6191b86211dd13df6613bdb3481d8d5c.png" alt="">
## Windows
使用 Windows 系统 + CLion 的同学,可以在 Win10 应用商店中下载一个 Ubuntu 版本的 Windows 子系统,即 WSL然后在 CLion 中配置工程的环境为 WSL 即可编译运行。
## 学习路径
也许你刚刚入门,一直对网络编程的学习路径有很大的困惑,我在这里统一回复一下。
我觉得学习网络编程技术必须要过一道语言关这个语言就是C语言。专栏本身也是基于C语言的。虽然现代工业化语言如Java、Golang等已经足够强大但是你要知道在这些语言的背后无一例外的总是有C语言的影子。C语言是可以和系统直接交互的语言无论是系统调用还是内核实现都和C语言有非常直接的联系比如Java本身就是用C++实现的Golang虽然现在可以自举也就是可以使用Golang实现Golang本身但是它的第一版也是用C/C++实现的。
我不建议一开始就学习C++语言,在我看来, C++语言在C语言原来的基础上做了很多语言层面的增强而这些增强的语言特性例如模板、继承、虚函数、boost语言库等对于刚开始接触底层的人显得有些艰深。
学习一门编程语言显然不是学习这门语言的控制流或者变量类型而是抓住这门语言的精髓。我认为C语言的精髓包括数组和指针、结构体和函数。
C语言的地址、数组、指针可以帮助我们详细地理解计算机的体系结构一段数据怎样在内存中摆放怎么去访问等你可以在学习它们的过程中得到锤炼了解这些基础的编程理念。
有些同学一上来就啃“TCP/IP协议”我觉得对于实战来说显得过于着急。我们可以把“TCP/IP协议”当作编程过程中答疑解惑的好帮手有问题之后再从中寻找答案而不是急急忙忙就来啃这类书籍。说实话这类书籍理论性偏强有时候大段读下来也少有收获。
最好的办法,还是自己跟随一些入门书籍,或者我的这篇实战,尝试动手去写、去调试代码,这中间你会不断获得一些反馈,然后再和大家一起探讨,不断加深了解。
当你学到了一定阶段就可以给自己开一些小的任务比如写一个聊天室程序或者写一个HTTP服务器端程序带着任务去学习获得成就感的同时对网络编程的理解也随之更上一层楼了。
## 书籍推荐
我希望你可以通过这个专栏更好地了解网络编程,但是深入的学习还需要你自行去找更多的资料。我在这里给你推荐一些书,这些书是各个领域的经典。
C语言入门方面我推荐 《C程序设计语言》这里是豆瓣链接你可以看下大家的评价以及他们的学习方式 [https://book.douban.com/subject/1139336/](https://book.douban.com/subject/1139336/)
UNIX网络编程方面强烈推荐Stevens大神的两卷本《UNIX网络编程》其中第一卷是讲套接字的第二卷是讲IPC进程间通信的。这套书也随书配备了源代码你如果有兴趣的话可以对代码进行改写和调试。
豆瓣链接在此: [https://book.douban.com/subject/1500149/](https://book.douban.com/subject/1500149/)
这套书的卷一基本上面面俱到地讲述了UNIX网络编程的方方面面但有时候稍显啰嗦特别是高性能高并发这块已经跟不上时代但你可以把注意力放在卷一的前半部分。
这套书翻译了好几版,就我的体验来说,比较推荐杨继张翻译的版本。
TCP/IP协议方面当然是推荐Stevens的大作《TCP/IP详解》, 这套书总共有三卷第一卷讲协议第二卷讲实现第三卷讲TCP事务。我在这里推荐第一卷第二卷的实现是基于BSD的代码讲解的就不推荐了。我想如果你想看源码的话还是推荐看Linux的毕竟我们用的比较多。第三卷涉及的内容比较少见也不推荐了。
这套书各个出版社翻译了好多版本,你可以去豆瓣自行查看哪个版本评分比较高。
《TCP/IP详解 卷1协议》豆瓣链接如下
[https://book.douban.com/subject/1088054/](https://book.douban.com/subject/1088054/)
最后除了书籍外还有一个非常好的了解TCP的方法那就是查看RFC文档对于有一定英文能力的同学来说可以说是一个捷径。RFC最大的好处可以帮我们了解TCP发展的背景和脉络。
## 疑难解答
前面的内容算是我对你学习网络编程提供的一些小建议或者小帮助。接下来,我们正式进入到文章本身的内容。
在第5讲思考题部分中我出了这么一道题目“一段数据流从应用程序发送端一直到应用程序接收端总共经过了多少次拷贝”大家的回答五花八门。
我的本意可以用一张图来表示还记得TCP/IP层次模型么我想通过这么一个问题来展示TCP/IP分层的思想。
<img src="https://static001.geekbang.org/resource/image/50/8f/50c05c8509a1d8436273adbf8701bb8f.png" alt=""><br>
让我们先看发送端当应用程序将数据发送到发送缓冲区时调用的是send或write方法如果缓存中没有空间系统调用就会失败或者阻塞。我们说这个动作事实上是一次“显式拷贝”。而在这之后数据将会按照TCP/IP的分层再次进行拷贝这层的拷贝对我们来说就不是显式的了。
接下来轮到TCP协议栈工作创建Packet报文并把报文发送到传输队列中qdisc传输队列是一个典型的 FIFO 队列,队列的最大值可以通过 ifconfig 命令输出的 txqueuelen 来查看。通常情况下,这个值有几千报文大小。
TX ring 在网络驱动和网卡之间,也是一个传输请求的队列。
网卡作为物理设备工作在物理层,主要工作是把要发送的报文保存到内部的缓存中,并发送出去。
接下来再看接收端,报文首先到达网卡,由网卡保存在自己的接收缓存中,接下来报文被发送至网络驱动和网卡之间的 RX ring网络驱动从 RX ring 获取报文 ,然后把报文发送到上层。
这里值得注意的是,网络驱动和上层之间没有缓存,因为网络驱动使用 Napi 进行数据传输。因此,可以认为上层直接从 RX ring 中读取报文。
最后,报文的数据保存在套接字接收缓存中,应用程序从套接字接收缓存中读取数据。
这就是数据流从应用程序发送端,一直到应用程序接收端的整个过程,你看懂了吗?
上面的任何一个环节稍有积压,都会对程序性能产生影响。但好消息是,内核和网络设备供应商已经帮我们把一切都打点好了,我们看到和用到的,其实只是冰山上的一角而已。
这就是基础篇的总结与答疑部分,我先对之前基础篇的内容补充了一些资料,尽可能地为你学习网络编程提供方便,然后针对大家有明显疑惑的问题进行了解答,希望对你有所帮助。

View File

@@ -0,0 +1,227 @@
<audio id="audio" title="20 | 大名⿍⿍的select看我如何同时感知多个I/O事件" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/56/f2/560bf3007f63c911ce286cf778379df2.mp3"></audio>
你好我是盛延敏这里是网络编程实战的第20讲欢迎回来。
这一讲是性能篇的第一讲。在性能篇里我们将把注意力放到如何设计高并发高性能的网络服务器程序上。我希望通过这一模块的学习让你能够掌握多路复用、异步I/O、多线程等知识从而可以写出支持并发10K以上的高性能网络服务器程序。
还等什么呢?让我们开始吧。
## 什么是I/O多路复用
在[第11讲](https://time.geekbang.org/column/article/126126)中,我们设计了这样一个应用程序,该程序从标准输入接收数据输入,然后通过套接字发送出去,同时,该程序也通过套接字接收对方发送的数据流。
我们可以使用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
```
在这个函数中maxfd表示的是待测试的描述符基数它的值是待测试的最大描述符加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讲](https://time.geekbang.org/column/article/127900)超时的例子里曾经使用过。
第三个可能是将tv_sec和tv_usec都设置成0表示根本不等待检测完毕立即返回。这种情况使用得比较少。
## 程序例子
下面是一个具体的程序例子我们通过这个例子来理解select函数。
```
int main(int argc, char **argv) {
if (argc != 2) {
error(1, 0, &quot;usage: select01 &lt;IPaddress&gt;&quot;);
}
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(&amp;allreads);
FD_SET(0, &amp;allreads);
FD_SET(socket_fd, &amp;allreads);
for (;;) {
readmask = allreads;
int rc = select(socket_fd + 1, &amp;readmask, NULL, NULL, NULL);
if (rc &lt;= 0) {
error(1, errno, &quot;select failed&quot;);
}
if (FD_ISSET(socket_fd, &amp;readmask)) {
n = read(socket_fd, recv_line, MAXLINE);
if (n &lt; 0) {
error(1, errno, &quot;read error&quot;);
} else if (n == 0) {
error(1, 0, &quot;server terminated \n&quot;);
}
recv_line[n] = 0;
fputs(recv_line, stdout);
fputs(&quot;\n&quot;, stdout);
}
if (FD_ISSET(STDIN_FILENO, &amp;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(&quot;now sending %s\n&quot;, send_line);
size_t rt = write(socket_fd, send_line, strlen(send_line));
if (rt &lt; 0) {
error(1, errno, &quot;write failed &quot;);
}
printf(&quot;send bytes: %zu \n&quot;, rt);
}
}
}
}
```
程序的12行通过FD_ZERO初始化了一个描述符集合这个描述符读集合是空的
<img src="https://static001.geekbang.org/resource/image/ce/68/cea07eee264c1abf69c04aacfae56c68.png" alt=""><br>
接下来程序的第13和14行分别使用FD_SET将描述符0即标准输入以及连接套接字描述符3设置为待检测
<img src="https://static001.geekbang.org/resource/image/71/f2/714f4fb84ab9afb39e51f6bcfc18def2.png" alt=""><br>
接下来的16-51行是循环检测这里我们没有阻塞在fgets或read调用而是通过select来检测套接字描述字有数据可读或者标准输入有数据可读。比如当用户通过标准输入使得标准输入描述符可读时返回的readmask的值为
<img src="https://static001.geekbang.org/resource/image/b9/bd/b90d1df438847d5e11d80485a23817bd.png" alt=""><br>
这个时候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调用完成之后记得要重置待测试集合。
## 思考题
和往常一样,给你布置两道思考题:
第一道, select可以对诸如UNIX管道(pipe)这样的描述字进行检测么?如果可以,检测的就绪条件是什么呢?
第二道根据我们前面的描述一个描述符集合哪些描述符被设置为1需要进行检测是完全可以知道的你认为select函数里一定需要传入描述字基数这个值么请你分析一下这样设计的目的又是什么呢
欢迎你在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。

View File

@@ -0,0 +1,256 @@
<audio id="audio" title="21 | poll另一种I/O多路复用" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c4/37/c42fac60c23b348ecd1ecf46ef1f3937.mp3"></audio>
你好我是盛延敏这是网络编程实战第21讲欢迎回来。
上一讲我们讲到了I/O多路复用技术并以select为核心展示了I/O多路复用技术的能力。select方法是多个UNIX平台支持的非常常见的I/O多路复用技术它通过描述符集合来表示检测的I/O对象通过三个不同的描述符集合来描述I/O事件 可读、可写和异常。但是select有一个缺点那就是所支持的文件描述符的个数是有限的。在Linux系统中select的默认最大值为1024。
那么有没有别的I/O多路复用技术可以突破文件描述符个数限制呢当然有这就是poll函数。这一讲我们就来学习一下另一种I/O多路复用的技术poll。
## 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的行为。
如果是一个&lt;0的数表示在有事件发生之前永远等待如果是0表示不阻塞进程立即返回如果是一个&gt;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 &lt; INIT_SIZE; i++) {
event_set[i].fd = -1;
}
for (;;) {
if ((ready_number = poll(event_set, INIT_SIZE, -1)) &lt; 0) {
error(1, errno, &quot;poll failed &quot;);
}
if (event_set[0].revents &amp; POLLRDNORM) {
socklen_t client_len = sizeof(client_addr);
connected_fd = accept(listen_fd, (struct sockaddr *) &amp;client_addr, &amp;client_len);
//找到一个可以记录该连接套接字的位置
for (i = 1; i &lt; INIT_SIZE; i++) {
if (event_set[i].fd &lt; 0) {
event_set[i].fd = connected_fd;
event_set[i].events = POLLRDNORM;
break;
}
}
if (i == INIT_SIZE) {
error(1, errno, &quot;can not hold so many clients&quot;);
}
if (--ready_number &lt;= 0)
continue;
}
for (i = 1; i &lt; INIT_SIZE; i++) {
int socket_fd;
if ((socket_fd = event_set[i].fd) &lt; 0)
continue;
if (event_set[i].revents &amp; (POLLRDNORM | POLLERR)) {
if ((n = read(socket_fd, buf, MAXLINE)) &gt; 0) {
if (write(socket_fd, buf, n) &lt; 0) {
error(1, errno, &quot;write error&quot;);
}
} else if (n == 0 || errno == ECONNRESET) {
close(socket_fd);
event_set[i].fd = -1;
} else {
error(1, errno, &quot;read error&quot;);
}
if (--ready_number &lt;= 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&gt; 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&gt; quit
Connection closed.
```
可以看到,这两个客户端互不影响,每个客户端输入的字符很快会被回显到客户端屏幕上。一个客户端断开连接,也不会影响到其他客户端。
## 总结
poll是另一种在各种UNIX系统上被广泛支持的I/O多路复用技术虽然名声没有select那么响能力一点不比select差而且因为可以突破select文件描述符的个数限制在高并发的场景下尤其占优势。这一讲我们编写了一个基于poll的服务器程序希望你从中学会poll的用法。
## 思考题
和往常一样,给你留两道思考题:
第一道在我们的程序里event_set数组的大小固定为INIT_SIZE这在实际的生产环境肯定是需要改进的。你知道如何改进吗
第二道如果我们进行了改进那么接下来把连接描述字connect_fd也加入到event_set里如何配合进行改造呢
欢迎你在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。

View File

@@ -0,0 +1,348 @@
<audio id="audio" title="22 | 非阻塞I/O提升性能的加速器" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/44/bc/44381bace6043f9a0bc1b5c4db6beebc.mp3"></audio>
你好我是盛延敏这里是网络编程实战第22讲欢迎回来。
在性能篇的前两讲中我分别介绍了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 &gt; 0) {
if ( (nwritten = write(fd, ptr, nleft)) &lt;= 0) {
/* 这里EAGAIN是非阻塞non-blocking情况下通知我们再次调用write() */
if (nwritten &lt; 0 &amp;&amp; errno == EAGAIN)
nwritten = 0;
else
return -1; /* 出错退出 */
}
/* 指针增大,剩下字节数变小*/
nleft -= nwritten;
ptr += nwritten;
}
return n;
}
```
下面我通过一张表来总结一下read和write在阻塞模式和非阻塞模式下的不同行为特性
<img src="https://static001.geekbang.org/resource/image/6e/aa/6e7a467bc6f5985eebbd94ef7de14aaa.png" alt=""><br>
关于read和write还有几个结论你需要把握住
1. read总是在接收缓冲区有数据时就立即返回不是等到应用程序给定的数据充满才返回。当接收缓冲区为空时阻塞模式会等待非阻塞模式立即返回-1并有EWOULDBLOCK或EAGAIN错误。
1. 和read不同阻塞模式下write只有在发送缓冲区足以容纳应用程序的输出字节时才返回而非阻塞模式下则是能写入多少就写入多少并返回实际写入的字节数。
1. 阻塞模式下的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, &amp;ling, sizeof(ling));
close(socket_fd);
```
服务器端使用select I/O多路复用不过监听套接字仍然是blocking的。如果监听套接字上有事件发生休眠5秒以便模拟高并发场景下的情形。
```
if (FD_ISSET(listen_fd, &amp;readset)) {
printf(&quot;listening socket readable\n&quot;);
sleep(5);
struct sockaddr_storage ss;
socklen_t slen = sizeof(ss);
int fd = accept(listen_fd, (struct sockaddr *) &amp;ss, &amp;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 &gt;= 'a' &amp;&amp; c &lt;= 'm') || (c &gt;= 'A' &amp;&amp; c &lt;= 'M'))
return c + 13;
else if ((c &gt;= 'n' &amp;&amp; c &lt;= 'z') || (c &gt;= 'N' &amp;&amp; c &lt;= '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-&gt;connect_fd = 0;
buffer-&gt;writeIndex = buffer-&gt;readIndex = buffer-&gt;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 &lt;= 0)
break;
for (i = 0; i &lt; result; ++i) {
if (buffer-&gt;writeIndex &lt; sizeof(buffer-&gt;buffer))
buffer-&gt;buffer[buffer-&gt;writeIndex++] = rot13_char(buf[i]);
if (buf[i] == '\n') {
buffer-&gt;readable = 1; //缓冲区可以读
}
}
}
if (result == 0) {
return 1;
} else if (result &lt; 0) {
if (errno == EAGAIN)
return 0;
return -1;
}
return 0;
}
int onSocketWrite(int fd, struct Buffer *buffer) {
while (buffer-&gt;readIndex &lt; buffer-&gt;writeIndex) {
ssize_t result = send(fd, buffer-&gt;buffer + buffer-&gt;readIndex, buffer-&gt;writeIndex - buffer-&gt;readIndex, 0);
if (result &lt; 0) {
if (errno == EAGAIN)
return 0;
return -1;
}
buffer-&gt;readIndex += result;
}
if (buffer-&gt;readIndex == buffer-&gt;writeIndex)
buffer-&gt;readIndex = buffer-&gt;writeIndex = 0;
buffer-&gt;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 &lt; FD_INIT_SIZE; ++i) {
buffer[i] = alloc_Buffer();
}
listen_fd = tcp_nonblocking_server_listen(SERV_PORT);
fd_set readset, writeset, exset;
FD_ZERO(&amp;readset);
FD_ZERO(&amp;writeset);
FD_ZERO(&amp;exset);
while (1) {
maxfd = listen_fd;
FD_ZERO(&amp;readset);
FD_ZERO(&amp;writeset);
FD_ZERO(&amp;exset);
// listener加入readset
FD_SET(listen_fd, &amp;readset);
for (i = 0; i &lt; FD_INIT_SIZE; ++i) {
if (buffer[i]-&gt;connect_fd &gt; 0) {
if (buffer[i]-&gt;connect_fd &gt; maxfd)
maxfd = buffer[i]-&gt;connect_fd;
FD_SET(buffer[i]-&gt;connect_fd, &amp;readset);
if (buffer[i]-&gt;readable) {
FD_SET(buffer[i]-&gt;connect_fd, &amp;writeset);
}
}
}
if (select(maxfd + 1, &amp;readset, &amp;writeset, &amp;exset, NULL) &lt; 0) {
error(1, errno, &quot;select error&quot;);
}
if (FD_ISSET(listen_fd, &amp;readset)) {
printf(&quot;listening socket readable\n&quot;);
sleep(5);
struct sockaddr_storage ss;
socklen_t slen = sizeof(ss);
int fd = accept(listen_fd, (struct sockaddr *) &amp;ss, &amp;slen);
if (fd &lt; 0) {
error(1, errno, &quot;accept failed&quot;);
} else if (fd &gt; FD_INIT_SIZE) {
error(1, 0, &quot;too many connections&quot;);
close(fd);
} else {
make_nonblocking(fd);
if (buffer[fd]-&gt;connect_fd == 0) {
buffer[fd]-&gt;connect_fd = fd;
} else {
error(1, 0, &quot;too many connections&quot;);
}
}
}
for (i = 0; i &lt; maxfd + 1; ++i) {
int r = 0;
if (i == listen_fd)
continue;
if (FD_ISSET(i, &amp;readset)) {
r = onSocketRead(i, buffer[i]);
}
if (r == 0 &amp;&amp; FD_ISSET(i, &amp;writeset)) {
r = onSocketWrite(i, buffer[i]);
}
if (r) {
buffer[i]-&gt;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下高性能网络编程的首选。
## 思考题
给你布置两道思考题:
第一道程序中第133行这个判断说明了什么如果要改进的话你有什么想法
```
else if (fd &gt; FD_INIT_SIZE) {
error(1, 0, &quot;too many connections&quot;);
close(fd);
```
第二道你可以仔细阅读一下数据读写部分Buffer的代码你觉得用一个Buffer对象而不是两个的目的是什么
欢迎在评论区写下你的思考,我会和你一起交流,也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。

View File

@@ -0,0 +1,365 @@
<audio id="audio" title="23 | Linux利器epoll的前世今生" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/48/6c/4820058629fb5514b1736a629b9fdd6c.mp3"></audio>
你好我是盛延敏这里是网络编程实战第23讲欢迎回来。
性能篇的前三讲非阻塞I/O加上I/O多路复用已经渐渐帮助我们在高性能网络编程这个领域搭建了初步的基石。但是离最终的目标还差那么一点如果说I/O多路复用帮我们打开了高性能网络编程的窗口那么今天的主题——epoll将为我们增添足够的动力。
这里有放置了一张图这张图来自The Linux Programming Interface(No Starch Press)。这张图直观地为我们展示了select、poll、epoll几种不同的I/O复用技术在面对不同文件描述符大小时的表现差异。
<img src="https://static001.geekbang.org/resource/image/fd/60/fd2e25f72a5103ef78c05c7ad2dab060.png" alt=""><br>
从图中可以明显地看到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_createepoll_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()的输入flags为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 &quot;lib/common.h&quot;
#define MAXEVENTS 128
char rot13_char(char c) {
if ((c &gt;= 'a' &amp;&amp; c &lt;= 'm') || (c &gt;= 'A' &amp;&amp; c &lt;= 'M'))
return c + 13;
else if ((c &gt;= 'n' &amp;&amp; c &lt;= 'z') || (c &gt;= 'N' &amp;&amp; c &lt;= '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, &quot;epoll create failed&quot;);
}
event.data.fd = listen_fd;
event.events = EPOLLIN | EPOLLET;
if (epoll_ctl(efd, EPOLL_CTL_ADD, listen_fd, &amp;event) == -1) {
error(1, errno, &quot;epoll_ctl add listen fd failed&quot;);
}
/* Buffer where events are returned */
events = calloc(MAXEVENTS, sizeof(event));
while (1) {
n = epoll_wait(efd, events, MAXEVENTS, -1);
printf(&quot;epoll_wait wakeup\n&quot;);
for (i = 0; i &lt; n; i++) {
if ((events[i].events &amp; EPOLLERR) ||
(events[i].events &amp; EPOLLHUP) ||
(!(events[i].events &amp; EPOLLIN))) {
fprintf(stderr, &quot;epoll error\n&quot;);
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 *) &amp;ss, &amp;slen);
if (fd &lt; 0) {
error(1, errno, &quot;accept failed&quot;);
} else {
make_nonblocking(fd);
event.data.fd = fd;
event.events = EPOLLIN | EPOLLET; //edge-triggered
if (epoll_ctl(efd, EPOLL_CTL_ADD, fd, &amp;event) == -1) {
error(1, errno, &quot;epoll_ctl add connection fd failed&quot;);
}
}
continue;
} else {
socket_fd = events[i].data.fd;
printf(&quot;get event on socket fd == %d \n&quot;, socket_fd);
while (1) {
char buf[512];
if ((n = read(socket_fd, buf, sizeof(buf))) &lt; 0) {
if (errno != EAGAIN) {
error(1, errno, &quot;read error&quot;);
close(socket_fd);
}
break;
} else if (n == 0) {
close(socket_fd);
break;
} else {
for (i = 0; i &lt; n; ++i) {
buf[i] = rot13_char(buf[i]);
}
if (write(socket_fd, buf, n) &lt; 0) {
error(1, errno, &quot;write error&quot;);
}
}
}
}
}
}
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&gt; 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](http:// <a href=)"&gt;点击这里看一下里面的讨论。
为什么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. Lets 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的时候我们一定要理解条件触发和边缘触发两种模式。条件触发的意思是只要满足事件的条件比如有数据需要读就一直不断地把这个事件传递给用户而边缘触发的意思是只有第一次满足条件的时候才触发之后就不会再传递同样的事件了。
## 思考题
理解完了epoll和往常一样我给你布置两道思考题
第一道你不妨试着修改一下第20讲中select的例子即在已连接套接字上有数据可读也不调用read函数去读看一看你的结果你认为select是边缘触发的还是条件触发的
第二道同样的修改一下第21讲poll的例子看看你的结果你认为poll是边缘触发的还是条件触发的
你可以在GitHub上上传你的代码并写出你的疑惑我会和你一起交流也欢迎把这篇文章分享给你的朋友或者同事一起交流一下。

View File

@@ -0,0 +1,227 @@
<audio id="audio" title="24 | C10K问题高并发模型设计" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fe/ab/fe9f862a9452c1b97bbcbfb98a5338ab.mp3"></audio>
你好我是盛延敏这里是网络编程实战第24讲欢迎回来。
在性能篇的前4讲里我们陆续讲解了select、poll、epoll等几种I/O多路复用技术以及非阻塞I/O模型为高性能网络编程提供了必要的知识储备。这一讲里我们了解一下历史上有名的C10K问题并借着C10K问题系统地梳理一下高性能网络编程的方法论。
## C10K问题
随着互联网的蓬勃发展一个非常重要的问题摆在计算机工业界面前。这个问题就是如何使用最低的成本满足高性能和高并发的需求。这个问题在过去可能不是一个严重的问题但是在2000年前后互联网用户的人数井喷如果说之前单机服务的用户数量还保持在一个比较低的水平比如说只有上百个用户那么在互联网逐渐普及的情况下服务于成千上万个用户就将是非常普遍的情形在这种情形下如果还按照之前单机的玩法成本就将超过人们想象只有超级有钱的大玩家才可以继续下去。
于是C10K问题应运而生。C10K问题是这样的如何在一台物理机上同时服务10000个用户这里C表示并发10K等于10000。得益于操作系统、编程语言的发展在现在的条件下普通用户使用Java Netty、Libevent等框架或库就可以轻轻松松写出支持并发超过10000的服务器端程序甚至于经过优化之后可以达到十万乃至百万的并发但在二十年前突破C10K问题可费了不少的心思是一个了不起的突破。
C10K问题是由一个叫Dan Kegel的工程师提出并总结归纳的你可以通过访问[http://www.kegel.com/c10k.html](http:// <a href=)"&gt;这个页面来获得最新有关这方面的信息。
## 操作系统层面
C10K问题本质上是一个操作系统问题要在一台主机上同时支持1万个连接意味着什么呢? 需要考虑哪些方面?
### 文件句柄
首先,通过前面的介绍,我们知道每个客户连接都代表一个文件描述符,一旦文件描述符不够用了,新的连接就会被放弃,产生如下的错误:
```
Socket/File:Can't open so many files
```
在Linux下单个进程打开的文件句柄数是有限制的没有经过修改的值一般都是1024。
```
$ulimit -n
1024
```
这意味着最多可以服务的连接数上限只能是1024。不过我们可以对这个值进行修改比如用 root 权限修改 /etc/sysctl.conf 文件使得系统可以支持10000个描述符上限。
```
fs.file-max = 10000
net.ipv4.ip_conntrack_max = 10000
net.ipv4.netfilter.ip_conntrack_max = 10000
```
### 系统内存
每个TCP连接占用的资源可不止一个连接套接字这么简单在前面的章节中我们多少接触到了类似发送缓冲区、接收缓冲区这些概念。每个TCP连接都需要占用一定的发送缓冲区和接收缓冲区。
这里有一段shell代码分别显示了在Linux 4.4.0下发送缓冲区和接收缓冲区的值。
```
$cat /proc/sys/net/ipv4/tcp_wmem
4096 16384 4194304
$ cat /proc/sys/net/ipv4/tcp_rmem
4096 87380 6291456
```
这三个值分别表示了最小分配值、默认分配值和最大分配值。按照默认分配值计算,一万个连接需要的内存消耗为:
```
发送缓冲区: 16384*10000 = 160M bytes
接收缓冲区: 87380*10000 = 880M bytes
```
当然我们的应用程序本身也需要一定的缓冲区来进行数据的收发为了方便我们假设每个连接需要128K的缓冲区那么1万个链接就需要大约1.2G的应用层缓冲。
这样我们可以得出大致的结论支持1万个并发连接内存并不是一个巨大的瓶颈。
### 网络带宽
假设1万个连接每个连接每秒传输大约1KB的数据那么带宽需要 10000 x 1KB/s x8 = 80Mbps。这在今天的动辄万兆网卡的时代简直小菜一碟。
## C10K问题解决之道
通过前面我们对操作系统层面的资源分析可以得出一个结论在系统资源层面C10K问题是可以解决的。
但是,能解决并不意味着可以很好地解决。我们知道,在网络编程中,涉及到频繁的用户态-内核态数据拷贝,设计不够好的程序可能在低并发的情况下工作良好,一旦到了高并发情形,其性能可能呈现出指数级别的损失。
举一个例子如果没有考虑好C10K问题一个基于select的经典程序可能在一台服务器上可以很好处理1000的并发用户但是在性能2倍的服务器上却往往并不能很好地处理2000的并发用户。
要想解决C10K问题就需要从两个层面上来统筹考虑。
第一个层面应用程序如何和操作系统配合感知I/O事件发生并调度处理在上万个套接字上的 I/O操作前面讲过的阻塞I/O、非阻塞I/O讨论的就是这方面的问题。
第二个层面,应用程序如何分配进程、线程资源来服务上万个连接?这在接下来会详细讨论。
这两个层面的组合就形成了解决C10K问题的几种解法方案下面我们一起来看。
### 阻塞I/O + 进程
这种方式最为简单直接每个连接通过fork派生一个子进程进行处理因为一个独立的子进程负责处理了该连接所有的I/O所以即便是阻塞I/O多个连接之间也不会互相影响。
这个方法虽然简单,但是效率不高,扩展性差,资源占用率高。
下面的伪代码描述了使用阻塞I/O为每个连接fork一个进程的做法
```
do{
accept connections
fork for conneced connection fd
process_run(fd)
}
```
虽然这个方式比较传统, 但是可以很好地帮我们理解父子进程、僵尸进程等,我们将在下一讲中详细讲一下如何使用这个技术设计一个服务器端程序。
### 阻塞I/O + 线程
进程模型占用的资源太大,幸运的是,还有一种轻量级的资源模型,这就是线程。
通过为每个连接调用pthread_create创建一个单独的线程也可以达到上面使用进程的效果。
```
do{
accept connections
pthread_create for conneced connection fd
thread_run(fd)
}while(true)
```
因为线程的创建是比较消耗资源的,况且不是每个连接在每个时刻都需要服务,因此,我们可以预先通过创建一个线程池,并在多个连接中复用线程池来获得某种效率上的提升。
```
create thread pool
do{
accept connections
get connection fd
push_queue(fd)
}while(true)
```
我将在第26讲中详细讲解这部分内容。
### 非阻塞I/O + readiness notification + 单线程
应用程序其实可以采取轮询的方式来对保存的套接字集合进行挨个询问从而找出需要进行I/O处理的套接字像给出的伪码一样其中is_readble和is_writeable可以通过对套接字调用read或write操作来判断。
```
for fd in fdset{
if(is_readable(fd) == true){
handle_read(fd)
}else if(is_writeable(fd)==true){
handle_write(fd)
}
}
```
但这个方法有一个问题如果这个fdset有一万个之多每次循环判断都会消耗大量的CPU时间而且极有可能在一个循环之内没有任何一个套接字准备好可读或者可写。
既然这样CPU的消耗太大那么干脆让操作系统来告诉我们哪个套接字可以读哪个套接字可以写。在这个结果发生之前我们把CPU的控制权交出去让操作系统来把宝贵的CPU时间调度给那些需要的进程这就是select、poll这样的I/O分发技术。
于是,程序就长成了这样:
```
do {
poller.dispatch()
for fd in registered_fdset{
if(is_readable(fd) == true){
handle_read(fd)
}else if(is_writeable(fd)==true){
handle_write(fd)
}
}while(ture)
```
第27讲中我将会讨论这样的技术实现。
但是这样的方法需要每次dispatch之后对所有注册的套接字进行逐个排查效率并不是最高的。如果dispatch调用返回之后只提供有 I/O事件或者I/O变化的套接字这样排查的效率不就高很多了么这就是前面我们讲到的epoll设计。
于是基于epoll的程序就长成了这样
```
do {
poller.dispatch()
for fd_event in active_event_set{
if(is_readable_event(fd_event) == true){
handle_read(fd_event)
}else if(is_writeable_event(fd_event)==true){
handle_write(fd_event)
}
}while(ture)
```
Linux是互联网的基石epoll也就成为了解决C10K问题的钥匙。FreeBSD上的kqueueWindows上的IOCPSolaris上的/dev/poll这些不同的操作系统提供的功能都是为了解决C10K问题。
### 非阻塞I/O + readiness notification +多线程
前面的做法是所有的I/O事件都在一个线程里分发如果我们把线程引入进来可以利用现代CPU多核的能力让每个核都可以作为一个I/O分发器进行I/O事件的分发。
这就是所谓的主从reactor模式。基于epoll/poll/select的I/O事件分发器可以叫做reactor也可以叫做事件驱动或者事件轮询eventloop
我没有把基于select/poll的所谓“level triggered”通知机制和基于epoll的“edge triggered”通知机制分开C10K问题总结里是分开的我觉得这只是reactor机制的实现高效性问题而不是编程模式的巨大区别。
从27讲开始我们就会引入reactor模式并使用一个自己编写的简单reactor框架来逐渐掌握它。
### 异步I/O+ 多线程
异步非阻塞 I/O 模型是一种更为高效的方式当调用结束之后请求立即返回由操作系统后台完成对应的操作当最终操作完成就会产生一个信号或者执行一个回调函数来完成I/O处理。
这就涉及到了Linux下的aio机制我们在第30讲对Linux下的aio机制进行简单的讨论。
## 总结
支持单机1万并发的问题被称为C10K问题为了解决C10K问题需要重点考虑两个方面的问题
- 如何和操作系统配合感知I/O事件的发生
- 如何分配和使用进程、线程资源来服务上万个连接?
基于这些组合产生了一些通用的解决方法在Linux下解决高性能问题的利器是非阻塞I/O加上epoll机制再利用多线程能力。
## 思考题
最后给你布置两道思考题:
第一道查询一下资料看看著名的Netty网络编程库用的是哪一种C10K解决方法呢
第二道现在大家又把眼光放到了更有挑战性的C10M问题即单机处理千万级并发你认为能实现吗挑战和瓶颈又在哪里呢
欢迎你在评论区写下你对这两个问题的思考,我会和你一起交流,也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。

View File

@@ -0,0 +1,209 @@
<audio id="audio" title="25 | 使用阻塞I/O和进程模型最传统的方式" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e6/39/e604edddecb85f843dd2a60fa12b0d39.mp3"></audio>
你好我是盛延敏这里是网络编程实战第25讲欢迎回来。
上一讲中我们讲到了C10K问题并引入了解决C10K问题的各种解法。其中最简单也是最有效的一种解决方法就是为每个连接创建一个独立的进程去服务。那么到编辑侧运营方案如第一季资源等如有底如何为每个连接客户创建一个进程来服务呢在这其中又需要特别注意什么呢今天我们就围绕这部分内容展开期望经过今天的学习你对父子进程、僵尸进程、使用进程处理连接等有一个比较直观的理解。
## 父进程和子进程
我们知道进程是程序执行的最小单位一个进程有完整的地址空间、程序计数器等如果想创建一个新的进程使用函数fork就可以。
```
pid_t fork(void)
返回在子进程中为0在父进程中为子进程ID若出错则为-1
```
如果你是第一次使用这个函数你会觉得难以理解的地方在于虽然我们的程序调用fork一次它却在父、子进程里各返回一次。在调用该函数的进程即为父进程中返回的是新派生的进程ID号在子进程中返回的值为0。想要知道当前执行的进程到底是父进程还是子进程只能通过返回值来进行判断。
fork函数实现的时候实际上会把当前父进程的所有相关值都克隆一份包括地址空间、打开的文件描述符、程序计数器等就连执行代码也会拷贝一份新派生的进程的表现行为和父进程近乎一样就好像是派生进程调用过fork函数一样。为了区别两个不同的进程实现者可以通过改变fork函数的栈空间值来判断对应到程序中就是返回值的不同。
这样就形成了编程范式:
```
if(fork() == 0){
do_child_process(); //子进程执行代码
}else{
do_parent_process(); //父进程执行代码
}
```
当一个子进程退出时系统内核还保留了该进程的若干信息比如退出状态。这样的进程如果不回收就会变成僵尸进程。在Linux下这样的“僵尸”进程会被挂到进程号为1的init进程上。所以由父进程派生出来的子进程也必须由父进程负责回收否则子进程就会变成僵尸进程。僵尸进程会占用不必要的内存空间如果量多到了一定数量级就会耗尽我们的系统资源。
有两种方式可以在子进程退出后回收资源分别是调用wait和waitpid函数。
```
pid_t wait(int *statloc);
pid_t waitpid(pid_t pid, int *statloc, int options);
```
函数wait和waitpid都可以返回两个值一个是函数返回值表示已终止子进程的进程ID号另一个则是通过statloc指针返回子进程终止的实际状态。这个状态可能的值为正常终止、被信号杀死、作业控制停止等。
如果没有已终止的子进程而是有一个或多个子进程在正常运行那么wait将阻塞直到第一个子进程终止。
waitpid可以认为是wait函数的升级版它的参数更多提供的控制权也更多。pid参数允许我们指定任意想等待终止的进程ID值-1表示等待第一个终止的子进程。options参数给了我们更多的控制选项。
处理子进程退出的方式一般是注册一个信号处理函数捕捉信号SIGCHILD信号然后再在信号处理函数里调用waitpid函数来完成子进程资源的回收。SIGCHLD是子进程退出或者中断时由内核向父进程发出的信号默认这个信号是忽略的。所以如果想在子进程退出时能回收它需要像下面一样注册一个SIGCHOLD函数。
```
signal(SIGCHLD, sigchld_handler);  
```
## 阻塞I/O的进程模型
为了说明使用阻塞I/O和进程模型我们假设有两个客户端服务器初始监听在套接字lisnted_fd上。当第一个客户端发起连接请求连接建立后产生出连接套接字此时父进程派生出一个子进程在子进程中使用连接套接字和客户端通信因此子进程不需要关心监听套接字只需要关心连接套接字父进程则相反将客户服务交给子进程来处理因此父进程不需要关心连接套接字只需要关心监听套接字。
这张图描述了从连接请求到连接建立,父进程派生子进程为客户服务。
<img src="https://static001.geekbang.org/resource/image/6c/a3/6cf70cf7f651273b38aac61cb61a88a3.png" alt=""><br>
假设父进程之后又接收了新的连接请求从accept调用返回新的已连接套接字父进程又派生出另一个子进程这个子进程用第二个已连接套接字为客户端服务。
这张图同样描述了这个过程。
<img src="https://static001.geekbang.org/resource/image/4f/08/4fb66c841fbca96f8b27c13c61a29608.png" alt=""><br>
现在,服务器端的父进程继续监听在套接字上,等待新的客户连接到来;两个子进程分别使用两个不同的连接套接字为两个客户服务。
## 程序讲解
我们将前面的内容串联起来,就是下面完整的一个基于进程模型的服务器端程序。
```
#include &quot;lib/common.h&quot;
#define MAX_LINE 4096
char rot13_char(char c) {
if ((c &gt;= 'a' &amp;&amp; c &lt;= 'm') || (c &gt;= 'A' &amp;&amp; c &lt;= 'M'))
return c + 13;
else if ((c &gt;= 'n' &amp;&amp; c &lt;= 'z') || (c &gt;= 'N' &amp;&amp; c &lt;= 'Z'))
return c - 13;
else
return c;
}
void child_run(int fd) {
char outbuf[MAX_LINE + 1];
size_t outbuf_used = 0;
ssize_t result;
while (1) {
char ch;
result = recv(fd, &amp;ch, 1, 0);
if (result == 0) {
break;
} else if (result == -1) {
perror(&quot;read&quot;);
break;
}
if (outbuf_used &lt; sizeof(outbuf)) {
outbuf[outbuf_used++] = rot13_char(ch);
}
if (ch == '\n') {
send(fd, outbuf, outbuf_used, 0);
outbuf_used = 0;
continue;
}
}
}
void sigchld_handler(int sig) {
while (waitpid(-1, 0, WNOHANG) &gt; 0);
return;
}
int main(int c, char **v) {
int listener_fd = tcp_server_listen(SERV_PORT);
signal(SIGCHLD, sigchld_handler);
while (1) {
struct sockaddr_storage ss;
socklen_t slen = sizeof(ss);
int fd = accept(listener_fd, (struct sockaddr *) &amp;ss, &amp;slen);
if (fd &lt; 0) {
error(1, errno, &quot;accept failed&quot;);
exit(1);
}
if (fork() == 0) {
close(listener_fd);
child_run(fd);
exit(0);
} else {
close(fd);
}
}
return 0;
}
```
程序的48行注册了一个信号处理函数用来回收子进程资源。函数sigchld_handler在一个循环体内调用了waitpid函数以便回收所有已终止的子进程。这里选项WNOHANG用来告诉内核即使还有未终止的子进程也不要阻塞在waitpid上。注意这里不可以使用wait因为wait函数在有未终止子进程的情况下没有办法不阻塞。
程序的58-62行通过判断fork的返回值为0进入子进程处理逻辑。按照前面的讲述子进程不需要关心监听套接字故而在这里关闭掉监听套接字listen_fd之后调用child_run函数使用已连接套接字fd来进行数据读写。第63行进入的是父进程处理逻辑父进程不需要关心连接套接字所以在这里关闭连接套接字。
还记得[第11讲](https://time.geekbang.org/column/article/126126)中讲到的close函数吗我们知道从父进程派生出的子进程同时也会复制一份描述字也就是说连接套接字和监听套接字的引用计数都会被加1而调用close函数则会对引用计数进行减1操作这样在套接字引用计数到0时才可以将套接字资源回收。所以这里的close函数非常重要缺少了它们就会引起服务器端资源的泄露。
child_run函数中通过一个while循环来不断和客户端进行交互依次读出字符之后进行了简单的转码如果读到回车符则将转码之后的结果通过连接套接字发送出去。这样的回显方式显得比较有“交互感”。
## 实验
我们启动该服务器监听在对应的端口43211上。
```
./fork01
```
再启动两个telnet客户端连接到43211端口每次通过标准输入和服务器端传输一些数据我们看到服务器和客户端的交互正常。
```
$telnet 127.0.0.1 43211
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
afasfa
nsnfsn
]
telnet&gt; quit
Connection closed.
```
```
$telnet 127.0.0.1 43211
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
agasgasg
ntnftnft
]
telnet&gt; quit
Connection closed.
```
客户端退出服务器端也在正常工作此时如果再通过telnet建立新的连接客户端和服务器端的数据传输也会正常进行。
至此,我们构建了一个完整的服务器端程序,可以并发处理多个不同的客户连接,互不干扰。
## 总结
使用阻塞I/O和进程模型为每一个连接创建一个独立的子进程来进行服务是一个非常简单有效的实现方式这种方式可能很难满足高性能程序的需求但好处在于实现简单。在实现这样的程序时我们需要注意两点
- 要注意对套接字的关闭梳理;
- 要注意对子进程进行回收,避免产生不必要的僵尸进程。
## 思考题
给你出两道思考题:
第一道,你可以查查资料,看看有没有比较著名的程序是使用这样的模式来构建的?
第二道程序中处理SIGCHLD信号时使用了一个循环来回收处理终止的子进程为什么要这么做呢如果不使用循环会有什么后果
欢迎你在评论区写下你的思考,我会和你一起交流,也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。

View File

@@ -0,0 +1,387 @@
<audio id="audio" title="26 | 使用阻塞I/O和线程模型换一种轻量的方式" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3c/3d/3ce7b95449c684eb6445234806b51c3d.mp3"></audio>
你好我是盛延敏这里是网络编程实战第26讲欢迎回来。
在前面一讲中,我们使用了进程模型来处理用户连接请求,进程切换上下文的代价是比较高的,幸运的是,有一种轻量级的模型可以处理多用户连接请求,这就是线程模型。这一讲里,我们就来了解一下线程模型。
线程thread是运行在进程中的一个“逻辑流”现代操作系统都允许在单进程中运行多个线程。线程由操作系统内核管理。每个线程都有自己的上下文context包括一个可以唯一标识线程的IDthread ID或者叫tid、栈、程序计数器、寄存器等。在同一个进程中所有的线程共享该进程的整个虚拟地址空间包括代码、数据、堆、共享库等。
在前面的程序中,我们没有显式使用线程,但这不代表线程没有发挥作用。实际上,每个进程一开始都会产生一个线程,一般被称为主线程,主线程可以再产生子线程,这样的主线程-子线程对可以叫做一个对等线程。
你可能会问,既然可以使用多进程来处理并发,为什么还要使用多线程模型呢?
简单来说在同一个进程下线程上下文切换的开销要比进程小得多。怎么理解线程上下文呢我们的代码被CPU执行的时候是需要一些数据支撑的比如程序计数器告诉CPU代码执行到哪里了寄存器里存了当前计算的一些中间值内存里放置了一些当前用到的变量等从一个计算场景切换到另外一个计算场景程序计数器、寄存器等这些值重新载入新场景的值就是线程的上下文切换。
## POSIX线程模型
POSIX线程是现代UNIX系统提供的处理线程的标准接口。POSIX定义的线程函数大约有60多个这些函数可以帮助我们创建线程、回收线程。接下来我们先看一个简单的例子程序。
```
int another_shared = 0;
void thread_run(void *arg) {
int *calculator = (int *) arg;
printf(&quot;hello, world, tid == %d \n&quot;, pthread_self());
for (int i = 0; i &lt; 1000; i++) {
*calculator += 1;
another_shared += 1;
}
}
int main(int c, char **v) {
int calculator;
pthread_t tid1;
pthread_t tid2;
pthread_create(&amp;tid1, NULL, thread_run, &amp;calculator);
pthread_create(&amp;tid2, NULL, thread_run, &amp;calculator);
pthread_join(tid1, NULL);
pthread_join(tid2, NULL);
printf(&quot;calculator is %d \n&quot;, calculator);
printf(&quot;another_shared is %d \n&quot;, another_shared);
}
```
thread_helloworld程序中主线程依次创建了两个子线程然后等待这两个子线程处理完毕之后终止。每个子线程都在对两个共享变量进行计算最后在主线程中打印出最后的计算结果。
程序的第18和19行分别调用了pthread_create创建了两个线程每个线程的入口都是thread_run函数这里我们使用了calculator这个全局变量并且通过传地址指针的方式将这个值传给了thread_run函数。当调用pthread_create结束子线程会立即执行主线程在此后调用了pthread_join函数等待子线程结束。
运行这个程序,很幸运,计算的结果是正确的。
```
$./thread-helloworld
hello, world, tid == 125607936
hello, world, tid == 126144512
calculator is 2000
another_shared is 2000
```
## 主要线程函数
### 创建线程
正如前面看到通过调用pthread_create函数来创建一个线程。这个函数的原型如下
```
int pthread_create(pthread_t *tid, const pthread_attr_t *attr,
           void *(*func)(void *), void *arg);
返回若成功则为0若出错则为正的Exxx值
```
每个线程都有一个线程IDtid唯一来标识其数据类型为pthread_t一般是unsigned int。pthread_create函数的第一个输出参数tid就是代表了线程ID如果创建线程成功tid就返回正确的线程ID。
每个线程都会有很多属性比如优先级、是否应该成为一个守护进程等这些值可以通过pthread_attr_t来描述一般我们不会特殊设置可以直接指定这个参数为NULL。
第三个参数为新线程的入口函数该函数可以接收一个参数arg类型为指针如果我们想给线程入口函数传多个值那么需要把这些值包装成一个结构体再把这个结构体的地址作为pthread_create的第四个参数在线程入口函数内再将该地址转为该结构体的指针对象。
在新线程的入口函数内可以执行pthread_self函数返回线程tid。
```
pthread_t pthread_self(void)
```
### 终止线程
终止一个线程最直接的方法是在父线程内调用以下函数:
```
void pthread_exit(void *status)
```
当调用这个函数之后,父线程会等待其他所有的子线程终止,之后父线程自己终止。
当然,如果一个子线程入口函数直接退出了,那么子线程也就自然终止了。所以,绝大多数的子线程执行体都是一个无限循环。
也可以通过调用pthread_cancel来主动终止一个子线程和pthread_exit不同的是它可以指定某个子线程终止。
```
int pthread_cancel(pthread_t tid)
```
### 回收已终止线程的资源
我们可以通过调用pthread_join回收已终止线程的资源
```
int pthread_join(pthread_t tid, void ** thread_return)
```
当调用pthread_join时主线程会阻塞直到对应tid的子线程自然终止。和pthread_cancel不同的是它不会强迫子线程终止。
### 分离线程
一个线程的重要属性是可结合的,或者是分离的。一个可结合的线程是能够被其他线程杀死和回收资源的;而一个分离的线程不能被其他线程杀死或回收资源。一般来说,默认的属性是可结合的。
我们可以通过调用pthread_detach函数可以分离一个线程
```
int pthread_detach(pthread_t tid)
```
在高并发的例子里每个连接都由一个线程单独处理在这种情况下服务器程序并不需要对每个子线程进行终止这样的话每个子线程可以在入口函数开始的地方把自己设置为分离的这样就能在它终止后自动回收相关的线程资源了就不需要调用pthread_join函数了。
## 每个连接一个线程处理
接下来,我们改造一下服务器端程序。我们的目标是这样:每次有新的连接到达后,创建一个新线程,而不是用新进程来处理它。
```
#include &quot;lib/common.h&quot;
extern void loop_echo(int);
void thread_run(void *arg) {
pthread_detach(pthread_self());
int fd = (int) arg;
loop_echo(fd);
}
int main(int c, char **v) {
int listener_fd = tcp_server_listen(SERV_PORT);
pthread_t tid;
while (1) {
struct sockaddr_storage ss;
socklen_t slen = sizeof(ss);
int fd = accept(listener_fd, (struct sockaddr *) &amp;ss, &amp;slen);
if (fd &lt; 0) {
error(1, errno, &quot;accept failed&quot;);
} else {
pthread_create(&amp;tid, NULL, &amp;thread_run, (void *) fd);
}
}
return 0;
}
```
这个程序的第18行阻塞调用在accept上一旦有新连接建立阻塞调用返回调用pthread_create创建一个子线程来处理这个连接。
描述连接最主要的是连接描述字这里通过强制把描述字转换为void *指针的方式完成了传值。如果你对这部分有点不理解建议看一下C语言相关的指针部分内容。我们这里可以简单总结一下虽然传的是一个指针但是这个指针里存放的并不是一个地址而是连接描述符的数值。
新线程入口函数thread_run里第6行使用了pthread_detach方法将子线程转变为分离的也就意味着子线程独自负责线程资源回收。第7行强制将指针转变为描述符数据和前面将描述字转换为void *指针对应第8行调用loop_echo方法处理这个连接的数据读写。
loop_echo的程序如下在接收客户端的数据之后再编码回送出去。
```
char rot13_char(char c) {
if ((c &gt;= 'a' &amp;&amp; c &lt;= 'm') || (c &gt;= 'A' &amp;&amp; c &lt;= 'M'))
return c + 13;
else if ((c &gt;= 'n' &amp;&amp; c &lt;= 'z') || (c &gt;= 'N' &amp;&amp; c &lt;= 'Z'))
return c - 13;
else
return c;
}
void loop_echo(int fd) {
char outbuf[MAX_LINE + 1];
size_t outbuf_used = 0;
ssize_t result;
while (1) {
char ch;
result = recv(fd, &amp;ch, 1, 0);
//断开连接或者出错
if (result == 0) {
break;
} else if (result == -1) {
error(1, errno, &quot;read error&quot;);
break;
}
if (outbuf_used &lt; sizeof(outbuf)) {
outbuf[outbuf_used++] = rot13_char(ch);
}
if (ch == '\n') {
send(fd, outbuf, outbuf_used, 0);
outbuf_used = 0;
continue;
}
}
}
```
运行这个程序之后开启多个telnet客户端可以看到这个服务器程序可以处理多个并发连接并回送数据。单独一个连接退出也不会影响其他连接的数据收发。
```
$telnet 127.0.0.1 43211
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
aaa
nnn
^]
telnet&gt; quit
Connection closed.
```
## 构建线程池处理多个连接
上面的服务器端程序虽然可以正常工作,不过它有一个缺点,那就是如果并发连接过多,就会引起线程的频繁创建和销毁。虽然线程切换的上下文开销不大,但是线程创建和销毁的开销却是不小的。
能不能对这个程序进行一些优化呢?
我们可以使用预创建线程池的方式来进行优化。在服务器端启动时,可以先按照固定大小预创建出多个线程,当有新连接建立时,往连接字队列里放置这个新连接描述字,线程池里的线程负责从连接字队列里取出连接描述字进行处理。
<img src="https://static001.geekbang.org/resource/image/d9/72/d976c7b993862f0dbef75354d2f49672.png" alt=""><br>
这个程序的关键是连接字队列的设计,因为这里既有往这个队列里放置描述符的操作,也有从这个队列里取出描述符的操作。
对此需要引入两个重要的概念一个是锁mutex一个是条件变量condition。锁很好理解加锁的意思就是其他线程不能进入条件变量则是在多个线程需要交互的情况下用来线程间同步的原语。
```
//定义一个队列
typedef struct {
int number; //队列里的描述字最大个数
int *fd; //这是一个数组指针
int front; //当前队列的头位置
int rear; //当前队列的尾位置
pthread_mutex_t mutex; //锁
pthread_cond_t cond; //条件变量
} block_queue;
//初始化队列
void block_queue_init(block_queue *blockQueue, int number) {
blockQueue-&gt;number = number;
blockQueue-&gt;fd = calloc(number, sizeof(int));
blockQueue-&gt;front = blockQueue-&gt;rear = 0;
pthread_mutex_init(&amp;blockQueue-&gt;mutex, NULL);
pthread_cond_init(&amp;blockQueue-&gt;cond, NULL);
}
//往队列里放置一个描述字fd
void block_queue_push(block_queue *blockQueue, int fd) {
//一定要先加锁,因为有多个线程需要读写队列
pthread_mutex_lock(&amp;blockQueue-&gt;mutex);
//将描述字放到队列尾的位置
blockQueue-&gt;fd[blockQueue-&gt;rear] = fd;
//如果已经到最后,重置尾的位置
if (++blockQueue-&gt;rear == blockQueue-&gt;number) {
blockQueue-&gt;rear = 0;
}
printf(&quot;push fd %d&quot;, fd);
//通知其他等待读的线程,有新的连接字等待处理
pthread_cond_signal(&amp;blockQueue-&gt;cond);
//解锁
pthread_mutex_unlock(&amp;blockQueue-&gt;mutex);
}
//从队列里读出描述字进行处理
int block_queue_pop(block_queue *blockQueue) {
//加锁
pthread_mutex_lock(&amp;blockQueue-&gt;mutex);
//判断队列里没有新的连接字可以处理,就一直条件等待,直到有新的连接字入队列
while (blockQueue-&gt;front == blockQueue-&gt;rear)
pthread_cond_wait(&amp;blockQueue-&gt;cond, &amp;blockQueue-&gt;mutex);
//取出队列头的连接字
int fd = blockQueue-&gt;fd[blockQueue-&gt;front];
//如果已经到最后,重置头的位置
if (++blockQueue-&gt;front == blockQueue-&gt;number) {
blockQueue-&gt;front = 0;
}
printf(&quot;pop fd %d&quot;, fd);
//解锁
pthread_mutex_unlock(&amp;blockQueue-&gt;mutex);
//返回连接字
return fd;
}
```
这里有block_queue相关的定义和实现并在关键的地方加了一些注释有几个地方需要特别注意
第一是记得对操作进行加锁和解锁这里是通过pthread_mutex_lock和pthread_mutex_unlock来完成的。
第二是当工作线程没有描述字可用时需要等待第43行通过调用pthread_cond_wait所有的工作线程等待有新的描述字可达。第32行主线程通知工作线程有新的描述符需要服务。
服务器端程序如下:
```
void thread_run(void *arg) {
pthread_t tid = pthread_self();
pthread_detach(tid);
block_queue *blockQueue = (block_queue *) arg;
while (1) {
int fd = block_queue_pop(blockQueue);
printf(&quot;get fd in thread, fd==%d, tid == %d&quot;, fd, tid);
loop_echo(fd);
}
}
int main(int c, char **v) {
int listener_fd = tcp_server_listen(SERV_PORT);
block_queue blockQueue;
block_queue_init(&amp;blockQueue, BLOCK_QUEUE_SIZE);
thread_array = calloc(THREAD_NUMBER, sizeof(Thread));
int i;
for (i = 0; i &lt; THREAD_NUMBER; i++) {
pthread_create(&amp;(thread_array[i].thread_tid), NULL, &amp;thread_run, (void *) &amp;blockQueue);
}
while (1) {
struct sockaddr_storage ss;
socklen_t slen = sizeof(ss);
int fd = accept(listener_fd, (struct sockaddr *) &amp;ss, &amp;slen);
if (fd &lt; 0) {
error(1, errno, &quot;accept failed&quot;);
} else {
block_queue_push(&amp;blockQueue, fd);
}
}
return 0;
}
```
有了描述字队列主程序变得非常简洁。第19-23行预创建了多个线程组成了一个线程池。28-32行在新连接建立后将连接描述字加入到队列中。
7-9行是工作线程的主循环从描述字队列中取出描述字对这个连接进行服务处理。
同样的运行这个程序之后开启多个telnet客户端可以看到这个服务器程序可以正常处理多个并发连接并回显。
```
$telnet 127.0.0.1 43211
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
aaa
nnn
^]
telnet&gt; quit
Connection closed.
```
和前面的程序相比线程创建和销毁的开销大大降低但因为线程池大小固定又因为使用了阻塞套接字肯定会出现有连接得不到及时服务的场景。这个问题的解决还是要回到我在开篇词里提到的方案上来多路I/O复用加上线程来处理仅仅使用阻塞I/O模型和线程是没有办法达到极致的高并发处理能力。
## 总结
这一讲,我们使用了线程来构建服务器端程序。一种是每次动态创建线程,另一种是使用线程池提高效率。和进程相比,线程的语义更轻量,使用的场景也更多。线程是高性能网络服务器必须掌握的核心知识,希望你能够通过本讲的学习,牢牢掌握它。
## 思考题
和往常一样,给你留两道思考题。
第一道,连接字队列的实现里,有一个重要情况没有考虑,就是队列里没有可用的位置了,想想看,如何对这种情况进行优化?
第二道我在讲到第一个hello-world计数器应用时说“结果是幸运”这是为什么呢怎么理解呢
欢迎你在评论区写下你的思考,我会和你一起思考,也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。

View File

@@ -0,0 +1,221 @@
<audio id="audio" title="27 | I/O多路复用遇上线程使用poll单线程处理所有I/O事件" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/45/01/4576a09c92b2d1d90e1b6373db513001.mp3"></audio>
你好我是盛延敏这里是网络编程实战第27讲欢迎回来。
我在前面两讲里分别使用了fork进程和pthread线程来处理多并发这两种技术使用简单但是性能却会随着并发数的上涨而快速下降并不能满足极端高并发的需求。就像第24讲中讲到的一样这个时候我们需要寻找更好的解决之道这个解决之道基本的思想就是I/O事件分发。
关于代码,你可以去[GitHub](https://github.com/froghui/yolanda)上查看或下载完整代码。
## 重温事件驱动
### 基于事件的程序设计: 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子进程的开销太大。
<img src="https://static001.geekbang.org/resource/image/f1/1c/f1045858bc79c5064903c25c6388051c.png" alt="">
### pthread
第26讲中我们使用了pthread_create创建子线程因为线程是比进程更轻量级的执行单位所以它的效率相比fork的方式有一定的提高。但是每次创建一个线程的开销仍然是不小的因此引入了线程池的概念预先创建出一个线程池在每次新连接达到时从线程池挑选出一个线程为之服务很好地解决了线程创建的开销。但是这个模式还是没有解决空闲连接占用资源的问题如果一个连接在一定时间内没有数据交互这个连接还是要占用一定的线程资源直到这个连接消亡为止。
<img src="https://static001.geekbang.org/resource/image/1c/2c/1c07131ab6ca03d3a5a9092ef20e0b2c.png" alt="">
### single reactor thread
前面讲到,事件驱动模式是解决高性能、高并发比较好的一种模式。为什么呢?
因为这种模式是符合大规模生产的需求的。我们的生活中遍地都是类似的模式。比如你去咖啡店喝咖啡,你点了一杯咖啡在一旁喝着,服务员也不会管你,等你有续杯需求的时候,再去和服务员提(触发事件),服务员满足了你的需求,你就继续可以喝着咖啡玩手机。整个柜台的服务方式就是一个事件驱动的方式。
这里有一张图解释了这一讲的设计模式。一个reactor线程上同时负责分发acceptor的事件、已连接套接字的I/O事件。
<img src="https://static001.geekbang.org/resource/image/b8/33/b8627a1a1d32da4b55ac74d4f0230f33.png" alt="">
### single reactor thread + worker threads
但是上述的设计模式有一个问题和I/O事件处理相比应用程序的业务逻辑处理是比较耗时的比如XML文件的解析、数据库记录的查找、文件资料的读取和传输、计算型工作的处理等这些工作相对而言比较独立它们会拖慢整个反应堆模式的执行效率。
所以将这些decode、compute、enode型工作放置到另外的线程池中和反应堆线程解耦是一个比较明智的选择。反应堆线程只负责处理I/O相关的工作业务逻辑相关的工作都被裁剪成一个一个的小任务放到线程池里由空闲的线程来执行。当结果完成后再交给反应堆线程由反应堆线程通过套接字将结果发送出去。
<img src="https://static001.geekbang.org/resource/image/7e/23/7e4505bb75fef4a4bb945e6dc3040823.png" alt="">
## 样例程序
从今天开始,我们会接触到为本课程量身定制的网络编程框架。使用这个网络编程框架的样例程序如下:
```
#include &lt;lib/acceptor.h&gt;
#include &quot;lib/common.h&quot;
#include &quot;lib/event_loop.h&quot;
#include &quot;lib/tcp_server.h&quot;
char rot13_char(char c) {
if ((c &gt;= 'a' &amp;&amp; c &lt;= 'm') || (c &gt;= 'A' &amp;&amp; c &lt;= 'M'))
return c + 13;
else if ((c &gt;= 'n' &amp;&amp; c &lt;= 'z') || (c &gt;= 'N' &amp;&amp; c &lt;= 'Z'))
return c - 13;
else
return c;
}
//连接建立之后的callback
int onConnectionCompleted(struct tcp_connection *tcpConnection) {
printf(&quot;connection completed\n&quot;);
return 0;
}
//数据读到buffer之后的callback
int onMessage(struct buffer *input, struct tcp_connection *tcpConnection) {
printf(&quot;get message from tcp connection %s\n&quot;, tcpConnection-&gt;name);
printf(&quot;%s&quot;, input-&gt;data);
struct buffer *output = buffer_new();
int size = buffer_readable_size(input);
for (int i = 0; i &lt; 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(&quot;write completed\n&quot;);
return 0;
}
//连接关闭之后的callback
int onConnectionClosed(struct tcp_connection *tcpConnection) {
printf(&quot;connection closed\n&quot;);
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事件交由不同的线程处理而这个分离不过是在应用程序层简单的参数配置而已。
## 思考题
和往常一样,给你留两道思考题:
1. 你可以试着修改一下onMessage方法把它变为期中作业中提到的cd、ls等command实现。
1. 文章里服务器端的decode-compute-encode是在哪里实现的你有什么办法来解决业务逻辑和I/O逻辑混在一起么
欢迎你在评论区写下你的思考或者在GitHub上上传你的代码也欢迎把这篇文章分享给你的朋友或者同事一起交流一下。

View File

@@ -0,0 +1,299 @@
<audio id="audio" title="28 | I/O多路复用进阶子线程使用poll处理连接I/O事件" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f3/e5/f30571eec9ba0ed5e361501f816742e5.mp3"></audio>
你好我是盛延敏这里是网络编程实战第28讲欢迎回来。
在前面的第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分发处理的效率。而且同一个套接字事件分发只会出现在一个反应堆线程中这会大大减少并发处理的锁开销。
<img src="https://static001.geekbang.org/resource/image/92/2a/9269551b14c51dc9605f43d441c5a92a.png" alt=""><br>
我来解释一下这张图我们的主反应堆线程一直在感知连接建立的事件如果有连接成功建立主反应堆线程通过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(&quot;port&quot;, SSL? &quot;8992&quot; : &quot;8023&quot;));
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();
}
}
}
```
<img src="https://static001.geekbang.org/resource/image/1e/b4/1e647269a5f51497bd5488b2a44444b4.png" alt=""><br>
这张图解释了主-从反应堆下加上worker线程池的处理模式。
主-从反应堆跟上面介绍的做法是一样的。和上面不一样的是这里将decode、compute、encode等CPU密集型的工作从I/O线程中拿走这些工作交给worker线程池来处理而且这些工作拆分成了一个个子任务进行。encode之后完成的结果再由sub-reactor的I/O线程发送出去。
## 样例程序
```
#include &lt;lib/acceptor.h&gt;
#include &quot;lib/common.h&quot;
#include &quot;lib/event_loop.h&quot;
#include &quot;lib/tcp_server.h&quot;
char rot13_char(char c) {
if ((c &gt;= 'a' &amp;&amp; c &lt;= 'm') || (c &gt;= 'A' &amp;&amp; c &lt;= 'M'))
return c + 13;
else if ((c &gt;= 'n' &amp;&amp; c &lt;= 'z') || (c &gt;= 'N' &amp;&amp; c &lt;= 'Z'))
return c - 13;
else
return c;
}
//连接建立之后的callback
int onConnectionCompleted(struct tcp_connection *tcpConnection) {
printf(&quot;connection completed\n&quot;);
return 0;
}
//数据读到buffer之后的callback
int onMessage(struct buffer *input, struct tcp_connection *tcpConnection) {
printf(&quot;get message from tcp connection %s\n&quot;, tcpConnection-&gt;name);
printf(&quot;%s&quot;, input-&gt;data);
struct buffer *output = buffer_new();
int size = buffer_readable_size(input);
for (int i = 0; i &lt; 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(&quot;write completed\n&quot;);
return 0;
}
//连接关闭之后的callback
int onConnectionClosed(struct tcp_connection *tcpConnection) {
printf(&quot;connection closed\n&quot;);
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(&quot;get message from tcp connection %s\n&quot;, tcpConnection-&gt;name);
printf(&quot;%s&quot;, input-&gt;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的实现上来看也遵循了这一原则。
## 思考题
和往常一样,给你留两道思考题:
第一道从日志输出中你还可以看到main-thread首先加入了fd为4的套接字这个是监听套接字很好理解。可是这里的main-thread又加入了一个fd为7的套接字这个套接字是干什么用的呢
第二道你可以试着修改一下服务器端的代码把decode-compute-encode部分使用线程或者线程池来处理。
欢迎你在评论区写下你的思考或者在GitHub上上传修改过的代码我会和你一起交流也欢迎把这篇文章分享给你的朋友或者同事一起交流一下。

View File

@@ -0,0 +1,328 @@
<audio id="audio" title="29 | 渐入佳境使用epoll和多线程模型" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/10/e7/109a9fa7002421538cb07284da8eeae7.mp3"></audio>
你好我是盛延敏这里是网络编程实战第29讲欢迎回来。
在前面的第27讲和第28讲中我介绍了基于poll事件分发的reactor反应堆模式以及主从反应堆模式。我们知道和poll相比Linux提供的epoll是一种更为高效的事件分发机制。在这一讲里我们将切换到epoll实现的主从反应堆模式并且分析一下为什么epoll的性能会强于poll等传统的事件分发机制。
## 如何切换到epoll
我已经将所有的代码已经放置到[GitHub](https://github.com/froghui/yolanda)上,你可以自行查看或下载。
我们的网络编程框架是可以同时支持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(&quot;set epoll as dispatcher, %s&quot;, eventLoop-&gt;thread_name);
eventLoop-&gt;eventDispatcher = &amp;epoll_dispatcher;
#else
yolanda_msgx(&quot;set poll as dispatcher, %s&quot;, eventLoop-&gt;thread_name);
eventLoop-&gt;eventDispatcher = &amp;poll_dispatcher;
#endif
eventLoop-&gt;event_dispatcher_data = eventLoop-&gt;eventDispatcher-&gt;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 &quot;sys/epoll.h&quot; EPOLL_EXISTS)
if (EPOLL_EXISTS)
# Linux下设置为epoll
set(EPOLL_ENABLE 1 CACHE INTERNAL &quot;enable epoll&quot;)
# Linux下也设置为poll
# set(EPOLL_ENABLE &quot;&quot; CACHE INTERNAL &quot;not enable epoll&quot;)
else ()
set(EPOLL_ENABLE &quot;&quot; CACHE INTERNAL &quot;not enable epoll&quot;)
endif ()
```
但是为了能让编译器使用到这个宏需要让CMake往config.h文件里写入这个宏的最终值configure_file命令就是起这个作用的。其中config.h.cmake是一个模板文件已经预先创建在根目录下。同时还需要让编译器include这个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讲](https://time.geekbang.org/column/article/146664)和[28讲](https://time.geekbang.org/column/article/148148)中的程序案例如何改为使用poll的呢
我们可以修改CMakeLists.txt文件把Linux下设置为poll的那段注释下的命令打开同时关闭掉原先设置为1的命令就可以了。 下面就是具体的示例代码。
```
# check epoll and add config.h for the macro compilation
include(CheckSymbolExists)
check_symbol_exists(epoll_create &quot;sys/epoll.h&quot; EPOLL_EXISTS)
if (EPOLL_EXISTS)
# Linux下也设置为poll
set(EPOLL_ENABLE &quot;&quot; CACHE INTERNAL &quot;not enable epoll&quot;)
else ()
set(EPOLL_ENABLE &quot;&quot; CACHE INTERNAL &quot;not enable epoll&quot;)
endif (
```
不管怎样现在我们得到了一个Linux下使用epoll作为事件分发的版本现在让我们使用它来编写程序吧。
## 样例程序
我们的样例程序和[第28讲](https://time.geekbang.org/column/article/148148)的一模一样只是现在我们的事件分发机制从poll切换到了epoll。
```
#include &lt;lib/acceptor.h&gt;
#include &quot;lib/common.h&quot;
#include &quot;lib/event_loop.h&quot;
#include &quot;lib/tcp_server.h&quot;
char rot13_char(char c) {
if ((c &gt;= 'a' &amp;&amp; c &lt;= 'm') || (c &gt;= 'A' &amp;&amp; c &lt;= 'M'))
return c + 13;
else if ((c &gt;= 'n' &amp;&amp; c &lt;= 'z') || (c &gt;= 'N' &amp;&amp; c &lt;= 'Z'))
return c - 13;
else
return c;
}
//连接建立之后的callback
int onConnectionCompleted(struct tcp_connection *tcpConnection) {
printf(&quot;connection completed\n&quot;);
return 0;
}
//数据读到buffer之后的callback
int onMessage(struct buffer *input, struct tcp_connection *tcpConnection) {
printf(&quot;get message from tcp connection %s\n&quot;, tcpConnection-&gt;name);
printf(&quot;%s&quot;, input-&gt;data);
struct buffer *output = buffer_new();
int size = buffer_readable_size(input);
for (int i = 0; i &lt; 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(&quot;write completed\n&quot;);
return 0;
}
//连接关闭之后的callback
int onConnectionClosed(struct tcp_connection *tcpConnection) {
printf(&quot;connection closed\n&quot;);
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。这其实也是网络编程框架应该考虑的部分。
我们希望框架可以对应用程序封装掉套接字读和写的部分转而提供的是针对缓冲区对象的读和写操作。这样一来从套接字收取数据、处理异常、发送数据等操作都被类似buffer这样的对象所封装和屏蔽应用程序所要做的事情就会变得更加简单从buffer对象中可以获取已接收到的字节流再进行应用层处理比如这里通过调用buffer_read_char函数从buffer中读取一个字节。
另外一方面框架也必须对应用程序提供套接字发送的接口接口的数据类型类似这里的buffer对象可以看到这里先生成了一个buffer对象之后将编码后的结果填充到buffer对象里最后调用tcp_connection_send_buffer将buffer对象里的数据通过套接字发送出去。
这里像onMessage、onConnectionClosed几个回调函数都是运行在子反应堆线程中的也就是说刚刚提到的生成buffer对象encode部分的代码是在子反应堆线程中执行的。这其实也是回调函数的内涵回调函数本身只是提供了类似Handlder的处理逻辑具体执行是由事件分发线程或者说是event loop线程发起的。
框架通过一层抽象让应用程序的开发者只需要看到回调函数回调函数中的对象也都是如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&gt; 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只处理acceptor套接字的事件表示的是连接的建立反应堆子线程的epoll_wait主要处理的是已连接套接字的读写事件。这幅图详细解释了这部分逻辑。
<img src="https://static001.geekbang.org/resource/image/16/dd/167e8e055d690a15f22cee8f114fb5dd.png" alt="">
## epoll的性能分析
epoll的性能凭什么就要比poll或者select好呢这要从两个角度来说明。
第一个角度是事件集合。在每次使用poll或select之前都需要准备一个感兴趣的事件集合系统内核拿到事件集合进行分析并在内核空间构建相应的数据结构来完成对事件集合的注册。而epoll则不是这样epoll维护了一个全局的事件集合通过epoll句柄可以操纵这个事件集合增加、删除或修改这个事件集合里的某个元素。要知道在绝大多数情况下事件集合的变化没有那么的大这样操纵系统内核就不需要每次重新扫描事件集合构建内核空间数据结构。
第二个角度是就绪列表。每次在使用poll或者select之后应用程序都需要扫描整个感兴趣的事件集合从中找出真正活动的事件这个列表如果增长到10K以上每次扫描的时间损耗也是惊人的。事实上很多情况下扫描完一圈可能发现只有几个真正活动的事件。而epoll则不是这样epoll返回的直接就是活动的事件列表应用程序减少了大量的扫描时间。
此外, epoll还提供了更高级的能力——边缘触发。[第23讲](https://time.geekbang.org/column/article/143245)通过一个直观的例子,讲解了边缘触发和条件触发的区别。
这里再举一个例子说明一下。
如果某个套接字有100个字节可以读边缘触发edge-triggered和条件触发level-triggered都会产生read ready notification事件如果应用程序只读取了50个字节边缘触发就会陷入等待而条件触发则会因为还有50个字节没有读取完不断地产生read ready notification事件。
在条件触发下level-triggered如果某个套接字缓冲区可以写会无限次返回write ready notification事件在这种情况下如果应用程序没有准备好不需要发送数据一定需要解除套接字上的ready notification事件否则CPU就直接跪了。
我们简单地总结一下,边缘触发只会产生一次活动事件,性能和效率更高。不过,程序处理起来要更为小心。
## 总结
本讲我们将程序框架切换到了epoll的版本和poll版本相比只是底层的框架做了更改上层应用程序不用做任何修改这也是程序框架强大的地方。和poll相比epoll从事件集合和就绪列表两个方面加强了程序性能是Linux下高性能网络程序的首选。
## 思考题
最后我给你布置两道思考题:
第一道,说说你对边缘触发和条件触发的理解。
第二道对于边缘触发和条件触发onMessage函数处理要注意什么
欢迎你在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,一起交流进步。

View File

@@ -0,0 +1,238 @@
<audio id="audio" title="30 | 真正的大杀器异步I/O探索" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f6/b8/f61c70e6b027c27fd79b1ce51b6007b8.mp3"></audio>
你好我是盛延敏这里是网络编程实战的第30讲欢迎回来。
在性能篇的前几讲中我们谈到了阻塞I/O、非阻塞I/O以及像select、poll、epoll等I/O多路复用技术并在此基础上结合线程技术实现了以事件分发为核心的reactor反应堆模式。你或许还听说过一个叫做Proactor的网络事件驱动模式这个Proactor模式和reactor模式到底有什么区别和联系呢在今天的内容中我们先讲述异步I/O再一起揭开以异步I/O为基础的proactor模式的面纱。
## 阻塞/非阻塞 VS 同步/异步
尽管在前面的课程中,多少都涉及到了阻塞、非阻塞、同步、异步的概念,但为了避免看见这些概念一头雾水,今天,我们就先来梳理一下这几个概念。
第一种是阻塞I/O。阻塞I/O发起的read请求线程会被挂起一直等到内核数据准备好并把数据从内核区域拷贝到应用程序的缓冲区中当拷贝过程完成read请求调用才返回。接下来应用程序就可以对缓冲区的数据进行数据解析。
<img src="https://static001.geekbang.org/resource/image/e7/9a/e7f477d5c2e902de5a23b0e90cf9339a.png" alt=""><br>
第二种是非阻塞I/O。非阻塞的read请求在数据未准备好的情况下立即返回应用程序可以不断轮询内核直到数据准备好内核将数据拷贝到应用程序缓冲并完成这次read调用。注意这里最后一次read调用获取数据的过程**是一个同步的过程。这里的同步指的是内核区域的数据拷贝到缓存区这个过程。**
<img src="https://static001.geekbang.org/resource/image/4f/0c/4f93d6e13fb78be2a937f962175c5b0c.png" alt=""><br>
每次让应用程序去轮询内核的I/O是否准备好是一个不经济的做法因为在轮询的过程中应用进程啥也不能干。于是像select、poll这样的I/O多路复用技术就隆重登场了。通过I/O事件分发当内核数据准备好时再通知应用程序进行操作。这个做法大大改善了应用进程对CPU的利用率在没有被通知的情况下应用进程可以使用CPU做其他的事情。
注意这里read调用获取数据的过程**也是一个同步的过程。**
<img src="https://static001.geekbang.org/resource/image/ea/dc/ea8552f28b0b630af702a9e7434f03dc.png" alt=""><br>
第一种阻塞I/O我想你已经比较了解了在阻塞I/O的情况下应用程序会被挂起直到获取数据。第二种非阻塞I/O和第三种基于非阻塞I/O的多路复用技术获取数据的操作不会被阻塞。
无论是第一种阻塞I/O还是第二种非阻塞I/O第三种基于非阻塞I/O的多路复用都是**同步调用技术。为什么这么说呢因为同步调用、异步调用的说法是对于获取数据的过程而言的前面几种最后获取数据的read操作调用都是同步的在read调用时内核将数据从内核空间拷贝到应用程序空间这个过程是在read函数中同步进行的如果内核实现的拷贝效率很差read调用就会在这个同步过程中消耗比较长的时间。**
而真正的异步调用则不用担心这个问题我们接下来就来介绍第四种I/O技术当我们发起aio_read之后就立即返回内核自动将数据从内核空间拷贝到应用程序空间这个拷贝过程是异步的内核自动完成的和前面的同步操作不一样应用程序并不需要主动发起拷贝动作。
<img src="https://static001.geekbang.org/resource/image/de/71/de97e727087775971f83c70c38d6f771.png" alt=""><br>
还记得[第22](https://time.geekbang.org/column/article/141573)[](https://time.geekbang.org/column/article/141573)中讲到的去书店买书的例子吗? 基于这个例子,针对以上的场景,我们可以这么理解。
第一种阻塞I/O就是你去了书店告诉老板你想要某本书然后你就一直在那里等着直到书店老板翻箱倒柜找到你想要的书。
第二种非阻塞I/O类似于你去了书店问老板有没有一本书老板告诉你没有你就离开了。一周以后你又来这个书店再问这个老板老板一查有了于是你买了这本书。
第三种基于非阻塞的I/O多路复用你来到书店告诉老板“老板到货给我打电话吧我再来付钱取书。”
第四种异步I/O就是你连去书店取书的过程也想省了你留下地址付了书费让老板到货时寄给你你直接在家里拿到就可以看了。
这里放置了一张表格总结了以上几种I/O模型。
<img src="https://static001.geekbang.org/resource/image/17/32/17191523d4dc62acf48915b7e601e832.png" alt="">
## aio_read和aio_write的用法
听起来异步I/O有一种高大上的感觉。其实异步I/O用起来倒是挺简单的。下面我们看一下一个具体的例子
```
#include &quot;lib/common.h&quot;
#include &lt;aio.h&gt;
const int BUF_SIZE = 512;
int main() {
int err;
int result_size;
// 创建一个临时文件
char tmpname[256];
snprintf(tmpname, sizeof(tmpname), &quot;/tmp/aio_test_%d&quot;, getpid());
unlink(tmpname);
int fd = open(tmpname, O_CREAT | O_RDWR | O_EXCL, S_IRUSR | S_IWUSR);
if (fd == -1) {
error(1, errno, &quot;open file failed &quot;);
}
char buf[BUF_SIZE];
struct aiocb aiocb;
//初始化buf缓冲写入的数据应该为0xfafa这样的,
memset(buf, 0xfa, BUF_SIZE);
memset(&amp;aiocb, 0, sizeof(struct aiocb));
aiocb.aio_fildes = fd;
aiocb.aio_buf = buf;
aiocb.aio_nbytes = BUF_SIZE;
//开始写
if (aio_write(&amp;aiocb) == -1) {
printf(&quot; Error at aio_write(): %s\n&quot;, strerror(errno));
close(fd);
exit(1);
}
//因为是异步的,需要判断什么时候写完
while (aio_error(&amp;aiocb) == EINPROGRESS) {
printf(&quot;writing... \n&quot;);
}
//判断写入的是否正确
err = aio_error(&amp;aiocb);
result_size = aio_return(&amp;aiocb);
if (err != 0 || result_size != BUF_SIZE) {
printf(&quot; aio_write failed() : %s\n&quot;, strerror(err));
close(fd);
exit(1);
}
//下面准备开始读数据
char buffer[BUF_SIZE];
struct aiocb cb;
cb.aio_nbytes = BUF_SIZE;
cb.aio_fildes = fd;
cb.aio_offset = 0;
cb.aio_buf = buffer;
// 开始读数据
if (aio_read(&amp;cb) == -1) {
printf(&quot; air_read failed() : %s\n&quot;, strerror(err));
close(fd);
}
//因为是异步的,需要判断什么时候读完
while (aio_error(&amp;cb) == EINPROGRESS) {
printf(&quot;Reading... \n&quot;);
}
// 判断读是否成功
int numBytes = aio_return(&amp;cb);
if (numBytes != -1) {
printf(&quot;Success.\n&quot;);
} else {
printf(&quot;Error.\n&quot;);
}
// 清理文件句柄
close(fd);
return 0;
}
```
这个程序展示了如何使用aio系列函数来完成异步读写。主要用到的函数有:
- aio_write用来向内核提交异步写操作
- aio_read用来向内核提交异步读操作
- aio_error获取当前异步操作的状态
- aio_return获取异步操作读、写的字节数。
这个程序一开始使用aio_write方法向内核提交了一个异步写文件的操作。第23-27行是这个异步写操作的结构体。结构体aiocb是应用程序和操作系统内核传递的异步申请数据结构这里我们使用了文件描述符、缓冲区指针aio_buf以及需要写入的字节数aio_nbytes。
```
struct aiocb {
int aio_fildes; /* File descriptor */
off_t aio_offset; /* File offset */
volatile void *aio_buf; /* Location of buffer */
size_t aio_nbytes; /* Length of transfer */
int aio_reqprio; /* Request priority offset */
struct sigevent aio_sigevent; /* Signal number and value */
int aio_lio_opcode; /* Operation to be performed */
};
```
这里我们用了一个0xfa的缓冲区这在后面的演示中可以看到结果。
30-34行向系统内核申请了这个异步写操作并且在37-39行查询异步动作的结果当其结束时在42-48行判断写入的结果是否正确。
紧接着我们使用了aio_read从文件中读取这些数据。为此我们准备了一个新的aiocb结构体告诉内核需要把数据拷贝到buffer这个缓冲区中和异步写一样发起异步读之后在第65-67行一直查询异步读动作的结果。
接下来运行这个程序,我们看到屏幕上打印出一系列的字符,显示了这个操作是有内核在后台帮我们完成的。
```
./aio01
writing...
writing...
writing...
writing...
writing...
writing...
writing...
writing...
writing...
writing...
writing...
writing...
writing...
writing...
Reading...
Reading...
Reading...
Reading...
Reading...
Reading...
Reading...
Reading...
Reading...
Success.
```
打开/tmp目录下的aio_test_xxxx文件可以看到这个文件成功写入了我们期望的数据。<br>
<img src="https://static001.geekbang.org/resource/image/27/90/2759999db41b8b4e7c493f7513c75890.png" alt=""><br>
请注意,以上的读写,都不需要我们在应用程序里再发起调用,系统内核直接帮我们做好了。
## Linux下socket套接字的异步支持
aio系列函数是由POSIX定义的异步操作接口可惜的是Linux下的aio操作不是真正的操作系统级别支持的它只是由GNU libc库函数在用户空间借由pthread方式实现的而且仅仅针对磁盘类I/O套接字I/O不支持。
也有很多Linux的开发者尝试在操作系统内核中直接支持aio例如一个叫做Ben LaHaise的人就将aio实现成功merge到2.5.32中这部分能力是作为patch存在的但是它依旧不支持套接字。
Solaris倒是有真正的系统系别的aio不过还不是很确定它在套接字上的性能表现特别是和磁盘I/O相比效果如何。
综合以上结论就是Linux下对异步操作的支持非常有限这也是为什么使用epoll等多路分发技术加上非阻塞I/O来解决Linux下高并发高性能网络I/O问题的根本原因。
## Windows下的IOCP和Proactor模式
和Linux不同Windows下实现了一套完整的支持套接字的异步编程接口这套接口一般被叫做IOCompletetionPort(IOCP)。
这样就产生了基于IOCP的所谓Proactor模式。
和Reactor模式一样Proactor模式也存在一个无限循环运行的event loop线程但是不同于Reactor模式这个线程并不负责处理I/O调用它只是负责在对应的read、write操作完成的情况下分发完成事件到不同的处理函数。
这里举一个HTTP服务请求的例子来说明
1. 客户端发起一个GET请求
1. 这个GET请求对应的字节流被内核读取完成内核将这个完成事件放置到一个队列中
1. event loop线程也就是Poractor从这个队列里获取事件根据事件类型分发到不同的处理函数上比如一个http handle的onMessage解析函数
1. HTTP request解析函数完成报文解析
1. 业务逻辑处理,比如读取数据库的记录;
1. 业务逻辑处理完成开始encode完成之后发起一个异步写操作
1. 这个异步写操作被内核执行,完成之后这个异步写操作被放置到内核的队列中;
1. Proactor线程获取这个完成事件分发到HTTP handler的onWriteCompled方法执行。
从这个例子可以看出由于系统内核提供了真正的“异步”操作Proactor不会再像Reactor一样每次感知事件后再调用read、write方法完成数据的读写它只负责感知事件完成并由对应的handler发起异步读写请求I/O读写操作本身是由系统内核完成的。和前面看到的aio的例子一样这里需要传入数据缓冲区的地址等信息这样系统内核才可以自动帮我们把数据的读写工作完成。
无论是Reactor模式还是Proactor模式都是一种基于事件分发的网络编程模式。**Reactor模式是基于待完成的I/O事件而Proactor模式则是基于已完成的I/O事件**,两者的本质,都是借由事件分发的思想,设计出可兼容、可扩展、接口友好的一套程序框架。
## 总结
和同步I/O相比异步I/O的读写动作由内核自动完成不过在Linux下目前仅仅支持简单的基于本地文件的aio异步操作这也使得我们在编写高性能网络程序时首选Reactor模式借助epoll这样的I/O分发技术完成开发而Windows下的IOCP则是一种异步I/O的技术并由此产生了和Reactor齐名的Proactor模式借助这种模式可以完成Windows下高性能网络程序设计。
## 思考题
和往常一样,给你布置两道思考题:
1. 你可以查一查Linux的资料看看为了在内核层面支持完全的异步I/OLinux的世界里都发生了什么
1. 在例子程序里aio_error一直处于占用CPU轮询异步操作的状态有没有别的方法可以改进一下比如挂起调用者、设置超时时间等
欢迎你在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,一起交流进步一下。

View File

@@ -0,0 +1,831 @@
<audio id="audio" title="31丨性能篇答疑epoll源码深度剖析" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/18/00/18e13085c3d778edecb408b321e3a800.mp3"></audio>
你好,我是盛延敏,今天是网络编程实战性能篇的答疑模块,欢迎回来。
在性能篇中我主要围绕C10K问题进行了深入剖析最后引出了事件分发机制和多线程。可以说基于epoll的事件分发能力是Linux下高性能网络编程的不二之选。如果你觉得还不过瘾期望有更深刻的认识和理解那么在性能篇的答疑中我就带你一起梳理一下epoll的源代码从中我们一定可以有更多的发现和领悟。
今天的代码有些多,建议你配合文稿收听音频。
## 基本数据结构
在开始研究源代码之前我们先看一下epoll中使用的数据结构分别是eventpoll、epitem和eppoll_entry。
我们先看一下eventpoll这个数据结构这个数据结构是我们在调用epoll_create之后内核侧创建的一个句柄表示了一个epoll实例。后续如果我们再调用epoll_ctl和epoll_wait等都是对这个eventpoll数据进行操作这部分数据会被保存在epoll_create创建的匿名文件file的private_data字段中。
```
/*
* This structure is stored inside the &quot;private_data&quot; member of the file
* structure and represents the main data structure for the eventpoll
* interface.
*/
struct eventpoll {
/* Protect the access to this structure */
spinlock_t lock;
/*
* This mutex is used to ensure that files are not removed
* while epoll is using them. This is held during the event
* collection loop, the file cleanup path, the epoll file exit
* code and the ctl operations.
*/
struct mutex mtx;
/* Wait queue used by sys_epoll_wait() */
//这个队列里存放的是执行epoll_wait从而等待的进程队列
wait_queue_head_t wq;
/* Wait queue used by file-&gt;poll() */
//这个队列里存放的是该eventloop作为poll对象的一个实例加入到等待的队列
//这是因为eventpoll本身也是一个file, 所以也会有poll操作
wait_queue_head_t poll_wait;
/* List of ready file descriptors */
//这里存放的是事件就绪的fd列表链表的每个元素是下面的epitem
struct list_head rdllist;
/* RB tree root used to store monitored fd structs */
//这是用来快速查找fd的红黑树
struct rb_root_cached rbr;
/*
* This is a single linked list that chains all the &quot;struct epitem&quot; that
* happened while transferring ready events to userspace w/out
* holding -&gt;lock.
*/
struct epitem *ovflist;
/* wakeup_source used when ep_scan_ready_list is running */
struct wakeup_source *ws;
/* The user that created the eventpoll descriptor */
struct user_struct *user;
//这是eventloop对应的匿名文件充分体现了Linux下一切皆文件的思想
struct file *file;
/* used to optimize loop detection check */
int visited;
struct list_head visited_list_link;
#ifdef CONFIG_NET_RX_BUSY_POLL
/* used to track busy poll napi_id */
unsigned int napi_id;
#endif
};
```
你能看到在代码中我提到了epitem这个epitem结构是干什么用的呢
每当我们调用epoll_ctl增加一个fd时内核就会为我们创建出一个epitem实例并且把这个实例作为红黑树的一个子节点增加到eventpoll结构体中的红黑树中对应的字段是rbr。这之后查找每一个fd上是否有事件发生都是通过红黑树上的epitem来操作。
```
/*
* Each file descriptor added to the eventpoll interface will
* have an entry of this type linked to the &quot;rbr&quot; RB tree.
* Avoid increasing the size of this struct, there can be many thousands
* of these on a server and we do not want this to take another cache line.
*/
struct epitem {
union {
/* RB tree node links this structure to the eventpoll RB tree */
struct rb_node rbn;
/* Used to free the struct epitem */
struct rcu_head rcu;
};
/* List header used to link this structure to the eventpoll ready list */
//将这个epitem连接到eventpoll 里面的rdllist的list指针
struct list_head rdllink;
/*
* Works together &quot;struct eventpoll&quot;-&gt;ovflist in keeping the
* single linked chain of items.
*/
struct epitem *next;
/* The file descriptor information this item refers to */
//epoll监听的fd
struct epoll_filefd ffd;
/* Number of active wait queue attached to poll operations */
//一个文件可以被多个epoll实例所监听这里就记录了当前文件被监听的次数
int nwait;
/* List containing poll wait queues */
struct list_head pwqlist;
/* The &quot;container&quot; of this item */
//当前epollitem所属的eventpoll
struct eventpoll *ep;
/* List header used to link this item to the &quot;struct file&quot; items list */
struct list_head fllink;
/* wakeup_source used when EPOLLWAKEUP is set */
struct wakeup_source __rcu *ws;
/* The structure that describe the interested events and the source fd */
struct epoll_event event;
};
```
每次当一个fd关联到一个epoll实例就会有一个eppoll_entry产生。eppoll_entry的结构如下
```
/* Wait structure used by the poll hooks */
struct eppoll_entry {
/* List header used to link this structure to the &quot;struct epitem&quot; */
struct list_head llink;
/* The &quot;base&quot; pointer is set to the container &quot;struct epitem&quot; */
struct epitem *base;
/*
* Wait queue item that will be linked to the target file wait
* queue head.
*/
wait_queue_entry_t wait;
/* The wait queue head that linked the &quot;wait&quot; wait queue item */
wait_queue_head_t *whead;
};
```
## epoll_create
我们在使用epoll的时候首先会调用epoll_create来创建一个epoll实例。这个函数是如何工作的呢?
首先epoll_create会对传入的flags参数做简单的验证。
```
/* Check the EPOLL_* constant for consistency. */
BUILD_BUG_ON(EPOLL_CLOEXEC != O_CLOEXEC);
if (flags &amp; ~EPOLL_CLOEXEC)
return -EINVAL;
/*
```
接下来内核申请分配eventpoll需要的内存空间。
```
/* Create the internal data structure (&quot;struct eventpoll&quot;).
*/
error = ep_alloc(&amp;ep);
if (error &lt; 0)
return error;
```
在接下来epoll_create为epoll实例分配了匿名文件和文件描述字其中fd是文件描述字file是一个匿名文件。这里充分体现了UNIX下一切都是文件的思想。注意eventpoll的实例会保存一份匿名文件的引用通过调用fd_install函数将匿名文件和文件描述字完成了绑定。
这里还有一个特别需要注意的地方在调用anon_inode_get_file的时候epoll_create将eventpoll作为匿名文件file的private_data保存了起来这样在之后通过epoll实例的文件描述字来查找时就可以快速地定位到eventpoll对象了。
最后这个文件描述字作为epoll的文件句柄被返回给epoll_create的调用者。
```
/*
* Creates all the items needed to setup an eventpoll file. That is,
* a file structure and a free file descriptor.
*/
fd = get_unused_fd_flags(O_RDWR | (flags &amp; O_CLOEXEC));
if (fd &lt; 0) {
error = fd;
goto out_free_ep;
}
file = anon_inode_getfile(&quot;[eventpoll]&quot;, &amp;eventpoll_fops, ep,
O_RDWR | (flags &amp; O_CLOEXEC));
if (IS_ERR(file)) {
error = PTR_ERR(file);
goto out_free_fd;
}
ep-&gt;file = file;
fd_install(fd, file);
return fd;
```
## epoll_ctl
接下来我们看一下一个套接字是如何被添加到epoll实例中的。这就要解析一下epoll_ctl函数实现了。
### 查找epoll实例
首先epoll_ctl函数通过epoll实例句柄来获得对应的匿名文件这一点很好理解UNIX下一切都是文件epoll的实例也是一个匿名文件。
```
//获得epoll实例对应的匿名文件
f = fdget(epfd);
if (!f.file)
goto error_return;
```
接下来获得添加的套接字对应的文件这里tf表示的是target file即待处理的目标文件。
```
/* Get the &quot;struct file *&quot; for the target file */
//获得真正的文件,如监听套接字、读写套接字
tf = fdget(fd);
if (!tf.file)
goto error_fput;
```
再接下来进行了一系列的数据验证以保证用户传入的参数是合法的比如epfd真的是一个epoll实例句柄而不是一个普通文件描述符。
```
/* The target file descriptor must support poll */
//如果不支持poll那么该文件描述字是无效的
error = -EPERM;
if (!tf.file-&gt;f_op-&gt;poll)
goto error_tgt_fput;
...
```
如果获得了一个真正的epoll实例句柄就可以通过private_data获取之前创建的eventpoll实例了。
```
/*
* At this point it is safe to assume that the &quot;private_data&quot; contains
* our own data structure.
*/
ep = f.file-&gt;private_data;
```
### 红黑树查找
接下来epoll_ctl通过目标文件和对应描述字在红黑树中查找是否存在该套接字这也是epoll为什么高效的地方。红黑树RB-tree是一种常见的数据结构这里eventpoll通过红黑树跟踪了当前监听的所有文件描述字而这棵树的根就保存在eventpoll数据结构中。
```
/* RB tree root used to store monitored fd structs */
struct rb_root_cached rbr;
```
对于每个被监听的文件描述字都有一个对应的epitem与之对应epitem作为红黑树中的节点就保存在红黑树中。
```
/*
* Try to lookup the file inside our RB tree, Since we grabbed &quot;mtx&quot;
* above, we can be sure to be able to use the item looked up by
* ep_find() till we release the mutex.
*/
epi = ep_find(ep, tf.file, fd);
```
红黑树是一棵二叉树作为二叉树上的节点epitem必须提供比较能力以便可以按大小顺序构建出一棵有序的二叉树。其排序能力是依靠epoll_filefd结构体来完成的epoll_filefd可以简单理解为需要监听的文件描述字它对应到二叉树上的节点。
可以看到这个还是比较好理解的,按照文件的地址大小排序。如果两个相同,就按照文件文件描述字来排序。
```
struct epoll_filefd {
struct file *file; // pointer to the target file struct corresponding to the fd
int fd; // target file descriptor number
} __packed;
/* Compare RB tree keys */
static inline int ep_cmp_ffd(struct epoll_filefd *p1,
struct epoll_filefd *p2)
{
return (p1-&gt;file &gt; p2-&gt;file ? +1:
(p1-&gt;file &lt; p2-&gt;file ? -1 : p1-&gt;fd - p2-&gt;fd));
}
```
在进行完红黑树查找之后如果发现是一个ADD操作并且在树中没有找到对应的二叉树节点就会调用ep_insert进行二叉树节点的增加。
```
case EPOLL_CTL_ADD:
if (!epi) {
epds.events |= POLLERR | POLLHUP;
error = ep_insert(ep, &amp;epds, tf.file, fd, full_check);
} else
error = -EEXIST;
if (full_check)
clear_tfile_check_list();
break;
```
### ep_insert
ep_insert首先判断当前监控的文件值是否超过了/proc/sys/fs/epoll/max_user_watches的预设最大值如果超过了则直接返回错误。
```
user_watches = atomic_long_read(&amp;ep-&gt;user-&gt;epoll_watches);
if (unlikely(user_watches &gt;= max_user_watches))
return -ENOSPC;
```
接下来是分配资源和初始化动作。
```
if (!(epi = kmem_cache_alloc(epi_cache, GFP_KERNEL)))
return -ENOMEM;
/* Item initialization follow here ... */
INIT_LIST_HEAD(&amp;epi-&gt;rdllink);
INIT_LIST_HEAD(&amp;epi-&gt;fllink);
INIT_LIST_HEAD(&amp;epi-&gt;pwqlist);
epi-&gt;ep = ep;
ep_set_ffd(&amp;epi-&gt;ffd, tfile, fd);
epi-&gt;event = *event;
epi-&gt;nwait = 0;
epi-&gt;next = EP_UNACTIVE_PTR;
```
再接下来的事情非常重要ep_insert会为加入的每个文件描述字设置回调函数。这个回调函数是通过函数ep_ptable_queue_proc来进行设置的。这个回调函数是干什么的呢其实对应的文件描述字上如果有事件发生就会调用这个函数比如套接字缓冲区有数据了就会回调这个函数。这个函数就是ep_poll_callback。这里你会发现原来内核设计也是充满了事件回调的原理。
```
/*
* This is the callback that is used to add our wait queue to the
* target file wakeup lists.
*/
static void ep_ptable_queue_proc(struct file *file, wait_queue_head_t *whead,poll_table *pt)
{
struct epitem *epi = ep_item_from_epqueue(pt);
struct eppoll_entry *pwq;
if (epi&gt;nwait &gt;= 0 &amp;&amp; (pwq = kmem_cache_alloc(pwq_cache, GFP_KERNEL))) {
init_waitqueue_func_entry(&amp;pwq-&gt;wait, ep_poll_callback);
pwq-&gt;whead = whead;
pwq-&gt;base = epi;
if (epi-&gt;event.events &amp; EPOLLEXCLUSIVE)
add_wait_queue_exclusive(whead, &amp;pwq-&gt;wait);
else
add_wait_queue(whead, &amp;pwq-&gt;wait);
list_add_tail(&amp;pwq-&gt;llink, &amp;epi-&gt;pwqlist);
epi-&gt;nwait++;
} else {
/* We have to signal that an error occurred */
epi-&gt;nwait = -1;
}
}
```
### ep_poll_callback
ep_poll_callback函数的作用非常重要它将内核事件真正地和epoll对象联系了起来。它又是怎么实现的呢
首先通过这个文件的wait_queue_entry_t对象找到对应的epitem对象因为eppoll_entry对象里保存了wait_queue_entry_t根据wait_queue_entry_t这个对象的地址就可以简单计算出eppoll_entry对象的地址从而可以获得epitem对象的地址。这部分工作在ep_item_from_wait函数中完成。一旦获得epitem对象就可以寻迹找到eventpoll实例。
```
/*
* This is the callback that is passed to the wait queue wakeup
* mechanism. It is called by the stored file descriptors when they
* have events to report.
*/
static int ep_poll_callback(wait_queue_entry_t *wait, unsigned mode, int sync, void *key)
{
int pwake = 0;
unsigned long flags;
struct epitem *epi = ep_item_from_wait(wait);
struct eventpoll *ep = epi-&gt;ep;
```
接下来,进行一个加锁操作。
```
spin_lock_irqsave(&amp;ep-&gt;lock, flags);
```
下面对发生的事件进行过滤为什么需要过滤呢为了性能考虑ep_insert向对应监控文件注册的是所有的事件而实际用户侧订阅的事件未必和内核事件对应。比如用户向内核订阅了一个套接字的可读事件在某个时刻套接字的可写事件发生时并不需要向用户空间传递这个事件。
```
/*
* Check the events coming with the callback. At this stage, not
* every device reports the events in the &quot;key&quot; parameter of the
* callback. We need to be able to handle both cases here, hence the
* test for &quot;key&quot; != NULL before the event match test.
*/
if (key &amp;&amp; !((unsigned long) key &amp; epi-&gt;event.events))
goto out_unlock;
```
接下来,判断是否需要把该事件传递给用户空间。
```
if (unlikely(ep-&gt;ovflist != EP_UNACTIVE_PTR)) {
if (epi-&gt;next == EP_UNACTIVE_PTR) {
epi-&gt;next = ep-&gt;ovflist;
ep-&gt;ovflist = epi;
if (epi-&gt;ws) {
/*
* Activate ep-&gt;ws since epi-&gt;ws may get
* deactivated at any time.
*/
__pm_stay_awake(ep-&gt;ws);
}
}
goto out_unlock;
}
```
如果需要而且该事件对应的event_item不在eventpoll对应的已完成队列中就把它放入该队列以便将该事件传递给用户空间。
```
/* If this file is already in the ready list we exit soon */
if (!ep_is_linked(&amp;epi-&gt;rdllink)) {
list_add_tail(&amp;epi-&gt;rdllink, &amp;ep-&gt;rdllist);
ep_pm_stay_awake_rcu(epi);
}
```
我们知道当我们调用epoll_wait的时候调用进程被挂起在内核看来调用进程陷入休眠。如果该epoll实例上对应描述字有事件发生这个休眠进程应该被唤醒以便及时处理事件。下面的代码就是起这个作用的wake_up_locked函数唤醒当前eventpoll上的等待进程。
```
/*
* Wake up ( if active ) both the eventpoll wait list and the -&gt;poll()
* wait list.
*/
if (waitqueue_active(&amp;ep-&gt;wq)) {
if ((epi-&gt;event.events &amp; EPOLLEXCLUSIVE) &amp;&amp;
!((unsigned long)key &amp; POLLFREE)) {
switch ((unsigned long)key &amp; EPOLLINOUT_BITS) {
case POLLIN:
if (epi-&gt;event.events &amp; POLLIN)
ewake = 1;
break;
case POLLOUT:
if (epi-&gt;event.events &amp; POLLOUT)
ewake = 1;
break;
case 0:
ewake = 1;
break;
}
}
wake_up_locked(&amp;ep-&gt;wq);
}
```
### 查找epoll实例
epoll_wait函数首先进行一系列的检查例如传入的maxevents应该大于0。
```
/* The maximum number of event must be greater than zero */
if (maxevents &lt;= 0 || maxevents &gt; EP_MAX_EVENTS)
return -EINVAL;
/* Verify that the area passed by the user is writeable */
if (!access_ok(VERIFY_WRITE, events, maxevents * sizeof(struct epoll_event)))
return -EFAULT;
```
和前面介绍的epoll_ctl一样通过epoll实例找到对应的匿名文件和描述字并且进行检查和验证。
```
/* Get the &quot;struct file *&quot; for the eventpoll file */
f = fdget(epfd);
if (!f.file)
return -EBADF;
/*
* We have to check that the file structure underneath the fd
* the user passed to us _is_ an eventpoll file.
*/
error = -EINVAL;
if (!is_file_epoll(f.file))
goto error_fput;
```
还是通过读取epoll实例对应匿名文件的private_data得到eventpoll实例。
```
/*
* At this point it is safe to assume that the &quot;private_data&quot; contains
* our own data structure.
*/
ep = f.file-&gt;private_data;
```
接下来调用ep_poll来完成对应的事件收集并传递到用户空间。
```
/* Time to fish for events ... */
error = ep_poll(ep, events, maxevents, timeout);
```
### ep_poll
还记得[第23讲](https://time.geekbang.org/column/article/143245)里介绍epoll函数的时候对应的timeout值可以是大于0等于0和小于0么这里ep_poll就分别对timeout不同值的场景进行了处理。如果大于0则产生了一个超时时间如果等于0则立即检查是否有事件发生。
```
*/
static int ep_poll(struct eventpoll *ep, struct epoll_event __user *events,int maxevents, long timeout)
{
int res = 0, eavail, timed_out = 0;
unsigned long flags;
u64 slack = 0;
wait_queue_entry_t wait;
ktime_t expires, *to = NULL;
if (timeout &gt; 0) {
struct timespec64 end_time = ep_set_mstimeout(timeout);
slack = select_estimate_accuracy(&amp;end_time);
to = &amp;expires;
*to = timespec64_to_ktime(end_time);
} else if (timeout == 0) {
/*
* Avoid the unnecessary trip to the wait queue loop, if the
* caller specified a non blocking operation.
*/
timed_out = 1;
spin_lock_irqsave(&amp;ep-&gt;lock, flags);
goto check_events;
}
```
接下来尝试获得eventpoll上的锁
```
spin_lock_irqsave(&amp;ep-&gt;lock, flags);
```
获得这把锁之后检查当前是否有事件发生如果没有就把当前进程加入到eventpoll的等待队列wq中这样做的目的是当事件发生时ep_poll_callback函数可以把该等待进程唤醒。
```
if (!ep_events_available(ep)) {
/*
* Busy poll timed out. Drop NAPI ID for now, we can add
* it back in when we have moved a socket with a valid NAPI
* ID onto the ready list.
*/
ep_reset_busy_poll_napi_id(ep);
/*
* We don't have any available event to return to the caller.
* We need to sleep here, and we will be wake up by
* ep_poll_callback() when events will become available.
*/
init_waitqueue_entry(&amp;wait, current);
__add_wait_queue_exclusive(&amp;ep-&gt;wq, &amp;wait);
```
紧接着是一个无限循环, 这个循环中通过调用schedule_hrtimeout_range将当前进程陷入休眠CPU时间被调度器调度给其他进程使用当然当前进程可能会被唤醒唤醒的条件包括有以下四种
1. 当前进程超时;
1. 当前进程收到一个signal信号
1. 某个描述字上有事件发生;
1. 当前进程被CPU重新调度进入for循环重新判断如果没有满足前三个条件就又重新进入休眠。
对应的1、2、3都会通过break跳出循环直接返回。
```
//这个循环里,当前进程可能会被唤醒,唤醒的途径包括
//1.当前进程超时
//2.当前进行收到一个signal信号
//3.某个描述字上有事件发生
//对应的1.2.3都会通过break跳出循环
//第4个可能是当前进程被CPU重新调度进入for循环的判断如果没有满足1.2.3的条件,就又重新进入休眠
for (;;) {
/*
* We don't want to sleep if the ep_poll_callback() sends us
* a wakeup in between. That's why we set the task state
* to TASK_INTERRUPTIBLE before doing the checks.
*/
set_current_state(TASK_INTERRUPTIBLE);
/*
* Always short-circuit for fatal signals to allow
* threads to make a timely exit without the chance of
* finding more events available and fetching
* repeatedly.
*/
if (fatal_signal_pending(current)) {
res = -EINTR;
break;
}
if (ep_events_available(ep) || timed_out)
break;
if (signal_pending(current)) {
res = -EINTR;
break;
}
spin_unlock_irqrestore(&amp;ep-&gt;lock, flags);
//通过调用schedule_hrtimeout_range当前进程进入休眠CPU时间被调度器调度给其他进程使用
if (!schedule_hrtimeout_range(to, slack, HRTIMER_MODE_ABS))
timed_out = 1;
spin_lock_irqsave(&amp;ep-&gt;lock, flags);
}
```
如果进程从休眠中返回则将当前进程从eventpoll的等待队列中删除并且设置当前进程为TASK_RUNNING状态。
```
//从休眠中结束将当前进程从wait队列中删除设置状态为TASK_RUNNING接下来进入check_events来判断是否是有事件发生
__remove_wait_queue(&amp;ep-&gt;wq, &amp;wait);
__set_current_state(TASK_RUNNING);
```
最后调用ep_send_events将事件拷贝到用户空间。
```
//ep_send_events将事件拷贝到用户空间
/*
* Try to transfer events to user space. In case we get 0 events and
* there's still timeout left over, we go trying again in search of
* more luck.
*/
if (!res &amp;&amp; eavail &amp;&amp;
!(res = ep_send_events(ep, events, maxevents)) &amp;&amp; !timed_out)
goto fetch_events;
return res;
```
### ep_send_events
ep_send_events这个函数会将ep_send_events_proc作为回调函数并调用ep_scan_ready_list函数ep_scan_ready_list函数调用ep_send_events_proc对每个已经就绪的事件循环处理。
ep_send_events_proc循环处理就绪事件时会再次调用每个文件描述符的poll方法以便确定确实有事件发生。为什么这样做呢这是为了确定注册的事件在这个时刻还是有效的。
可以看到尽管ep_send_events_proc已经尽可能的考虑周全使得用户空间获得的事件通知都是真实有效的但还是有一定的概率当ep_send_events_proc再次调用文件上的poll函数之后用户空间获得的事件通知已经不再有效这可能是用户空间已经处理掉了或者其他什么情形。还记得[第22讲](https://time.geekbang.org/column/article/141573)吗在这种情况下如果套接字不是非阻塞的整个进程将会被阻塞这也是为什么将非阻塞套接字配合epoll使用作为最佳实践的原因。
在进行简单的事件掩码校验之后ep_send_events_proc将事件结构体拷贝到用户空间需要的数据结构中。这是通过__put_user方法完成的。
```
//这里对一个fd再次进行poll操作以确认事件
revents = ep_item_poll(epi, &amp;pt);
/*
* If the event mask intersect the caller-requested one,
* deliver the event to userspace. Again, ep_scan_ready_list()
* is holding &quot;mtx&quot;, so no operations coming from userspace
* can change the item.
*/
if (revents) {
if (__put_user(revents, &amp;uevent-&gt;events) ||
__put_user(epi-&gt;event.data, &amp;uevent-&gt;data)) {
list_add(&amp;epi-&gt;rdllink, head);
ep_pm_stay_awake(epi);
return eventcnt ? eventcnt : -EFAULT;
}
eventcnt++;
uevent++;
```
## Level-triggered VS Edge-triggered
在[前面的](https://time.geekbang.org/column/article/143245)[文章](https://time.geekbang.org/column/article/143245)里我们一直都在强调level-triggered和edge-triggered之间的区别。
从实现角度来看其实非常简单在ep_send_events_proc函数的最后针对level-triggered情况当前的epoll_item对象被重新加到eventpoll的就绪列表中这样在下一次epoll_wait调用时这些epoll_item对象就会被重新处理。
在前面我们提到在最终拷贝到用户空间有效事件列表中之前会调用对应文件的poll方法以确定这个事件是不是依然有效。所以如果用户空间程序已经处理掉该事件就不会被再次通知如果没有处理意味着该事件依然有效就会被再次通知。
```
//这里是Level-triggered的处理可以看到在Level-triggered的情况下这个事件被重新加回到ready list里面
//这样下一轮epoll_wait的时候这个事件会被重新check
else if (!(epi-&gt;event.events &amp; EPOLLET)) {
/*
* If this file has been added with Level
* Trigger mode, we need to insert back inside
* the ready list, so that the next call to
* epoll_wait() will check again the events
* availability. At this point, no one can insert
* into ep-&gt;rdllist besides us. The epoll_ctl()
* callers are locked out by
* ep_scan_ready_list() holding &quot;mtx&quot; and the
* poll callback will queue them in ep-&gt;ovflist.
*/
list_add_tail(&amp;epi-&gt;rdllink, &amp;ep-&gt;rdllist);
ep_pm_stay_awake(epi);
}
```
## epoll VS poll/select
最后我们从实现角度来说明一下为什么epoll的效率要远远高于poll/select。
首先poll/select先将要监听的fd从用户空间拷贝到内核空间, 然后在内核空间里面进行处理之后再拷贝给用户空间。这里就涉及到内核空间申请内存释放内存等等过程这在大量fd情况下是非常耗时的。而epoll维护了一个红黑树通过对这棵黑红树进行操作可以避免大量的内存申请和释放的操作而且查找速度非常快。
下面的代码就是poll/select在内核空间申请内存的展示。可以看到select 是先尝试申请栈上资源, 如果需要监听的fd比较多, 就会去申请堆空间的资源。
```
int core_sys_select(int n, fd_set __user *inp, fd_set __user *outp,
fd_set __user *exp, struct timespec64 *end_time)
{
fd_set_bits fds;
void *bits;
int ret, max_fds;
size_t size, alloc_size;
struct fdtable *fdt;
/* Allocate small arguments on the stack to save memory and be faster */
long stack_fds[SELECT_STACK_ALLOC/sizeof(long)];
ret = -EINVAL;
if (n &lt; 0)
goto out_nofds;
/* max_fds can increase, so grab it once to avoid race */
rcu_read_lock();
fdt = files_fdtable(current-&gt;files);
max_fds = fdt-&gt;max_fds;
rcu_read_unlock();
if (n &gt; max_fds)
n = max_fds;
/*
* We need 6 bitmaps (in/out/ex for both incoming and outgoing),
* since we used fdset we need to allocate memory in units of
* long-words.
*/
size = FDS_BYTES(n);
bits = stack_fds;
if (size &gt; sizeof(stack_fds) / 6) {
/* Not enough space in on-stack array; must use kmalloc */
ret = -ENOMEM;
if (size &gt; (SIZE_MAX / 6))
goto out_nofds;
alloc_size = 6 * size;
bits = kvmalloc(alloc_size, GFP_KERNEL);
if (!bits)
goto out_nofds;
}
fds.in = bits;
fds.out = bits + size;
fds.ex = bits + 2*size;
fds.res_in = bits + 3*size;
fds.res_out = bits + 4*size;
fds.res_ex = bits + 5*size;
...
```
第二select/poll从休眠中被唤醒时如果监听多个fd只要其中有一个fd有事件发生内核就会遍历内部的list去检查到底是哪一个事件到达并没有像epoll一样, 通过fd直接关联eventpoll对象快速地把fd直接加入到eventpoll的就绪列表中。
```
static int do_select(int n, fd_set_bits *fds, struct timespec64 *end_time)
{
...
retval = 0;
for (;;) {
unsigned long *rinp, *routp, *rexp, *inp, *outp, *exp;
bool can_busy_loop = false;
inp = fds-&gt;in; outp = fds-&gt;out; exp = fds-&gt;ex;
rinp = fds-&gt;res_in; routp = fds-&gt;res_out; rexp = fds-&gt;res_ex;
for (i = 0; i &lt; n; ++rinp, ++routp, ++rexp) {
unsigned long in, out, ex, all_bits, bit = 1, mask, j;
unsigned long res_in = 0, res_out = 0, res_ex = 0;
in = *inp++; out = *outp++; ex = *exp++;
all_bits = in | out | ex;
if (all_bits == 0) {
i += BITS_PER_LONG;
continue;
}
if (!poll_schedule_timeout(&amp;table, TASK_INTERRUPTIBLE,
to, slack))
timed_out = 1;
...
```
## 总结
在这次答疑中我希望通过深度分析epoll的源码实现帮你理解epoll的实现原理。
epoll维护了一棵红黑树来跟踪所有待检测的文件描述字黑红树的使用减少了内核和用户空间大量的数据拷贝和内存分配大大提高了性能。
同时epoll维护了一个链表来记录就绪事件内核在每个文件有事件发生时将自己登记到这个就绪事件列表中通过内核自身的文件file-eventpoll之间的回调和唤醒机制减少了对内核描述字的遍历大大加速了事件通知和检测的效率这也为level-triggered和edge-triggered的实现带来了便利。
通过对比poll/select的实现我们发现epoll确实克服了poll/select的种种弊端不愧是Linux下高性能网络编程的皇冠。我们应该感谢Linux社区的大神们设计了这么强大的事件分发机制让我们在Linux下可以享受高性能网络服务器带来的种种技术红利。

View File

@@ -0,0 +1,154 @@
<audio id="audio" title="10 | TIME_WAIT隐藏在细节下的魔鬼" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0c/49/0cd463a790c13c3414b50d77ebcfa249.mp3"></audio>
你好我是盛延敏这是网络编程实战的第10讲欢迎回来。
在前面的基础篇里我们对网络编程涉及到的基础知识进行了梳理主要内容包括C/S编程模型、TCP协议、UDP协议和本地套接字等内容。在提高篇里我将结合我的经验引导你对TCP和UDP进行更深入的理解。
学习完提高篇之后我希望你对如何提高TCP及UDP程序的健壮性有一个全面清晰的认识从而为深入理解性能篇打下良好的基础。
在前面的基础篇里我们了解了TCP四次挥手在四次挥手的过程中发起连接断开的一方会有一段时间处于TIME_WAIT的状态你知道TIME_WAIT是用来做什么的么在面试和实战中TIME_WAIT相关的问题始终是绕不过去的一道难题。下面就请跟随我一起找出隐藏在细节下的魔鬼吧。
## TIME_WAIT发生的场景
让我们先从一例线上故障说起。在一次升级线上应用服务之后我们发现该服务的可用性变得时好时坏一段时间可以对外提供服务一段时间突然又不可以大家都百思不得其解。运维同学登录到服务所在的主机上使用netstat命令查看后才发现主机上有成千上万处于TIME_WAIT状态的连接。
经过层层剖析后我们发现罪魁祸首就是TIME_WAIT。为什么呢我们这个应用服务需要通过发起TCP连接对外提供服务。每个连接会占用一个本地端口当在高并发的情况下TIME_WAIT状态的连接过多多到把本机可用的端口耗尽应用服务对外表现的症状就是不能正常工作了。当过了一段时间之后处于TIME_WAIT的连接被系统回收并关闭后释放出本地端口可供使用应用服务对外表现为可以正常工作。这样周而复始便会出现了一会儿不可以过一两分钟又可以正常工作的现象。
那么为什么会产生这么多的TIME_WAIT连接呢
这要从TCP的四次挥手说起。
<img src="https://static001.geekbang.org/resource/image/f3/e1/f34823ce42a49e4eadaf642a75d14de1.png" alt=""><br>
TCP连接终止时主机1先发送FIN报文主机2进入CLOSE_WAIT状态并发送一个ACK应答同时主机2通过read调用获得EOF并将此结果通知应用程序进行主动关闭操作发送FIN报文。主机1在接收到FIN报文后发送ACK应答此时主机1进入TIME_WAIT状态。
主机1在TIME_WAIT停留持续时间是固定的是最长分节生命期MSLmaximum 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通信产生影响。
<img src="https://static001.geekbang.org/resource/image/94/5f/945c60ae06d282dcc22ad3b868f1175f.png" alt=""><br>
所以TCP就设计出了这么一个机制经过2MSL这个时间足以让两个方向上的分组都被丢弃使得原来连接的分组在网络中都自然消失再出现的分组一定都是新化身所产生的。
划重点2MSL的时间是**从主机1接收到FIN后发送ACK开始计时的**如果在TIME_WAIT时间内因为主机1的ACK没有传输到主机2主机1又接收到了主机2重发的FIN报文那么2MSL时间将重新计时。道理很简单因为2MSL的时间目的是为了让旧连接的所有报文都能自然消亡现在主机1重新发送了ACK报文自然需要重新计时以便防止这个ACK报文对新可能的连接化身造成干扰。
## TIME_WAIT的危害
过多的TIME_WAIT的主要危害有两种。
第一是内存资源占用,这个目前看来不是太严重,基本可以忽略。
第二是对端口资源的占用一个TCP连接至少消耗一个本地端口。要知道端口资源也是有限的一般可以开启的端口为3276861000 ,也可以通过`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, &amp;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的套接字为新的连接所用。
那么什么是协议角度理解的安全可控呢?主要有两点:
1. 只适用于连接发起方C/S模型中的客户端
1. 对应的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状态的连接。
## 思考题
最后按照惯例,我留两道思考题,供你消化今天的内容。
1. 最大分组MSL是TCP分组在网络中存活的最长时间你知道这个最长时间是如何达成的换句话说是怎么样的机制可以保证在MSL达到之后报文就自然消亡了呢
1. RFC 1323引入了TCP时间戳那么这需要在发送方和接收方之间定义一个统一的时钟吗
欢迎你在评论区写下你的思考如果通过这篇文章你理解了TIME_WAIT欢迎你把这篇文章分享给你的朋友或者同事一起交流学习一下。

View File

@@ -0,0 +1,357 @@
<audio id="audio" title="11 | 优雅地关闭还是粗暴地关闭 ?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/de/c2/de42764fa62843355aeb21af76ce1bc2.mp3"></audio>
你好我是盛延敏这里是网络编程实战第11讲欢迎回来。
上一讲我们讲到了TCP的四次挥手其中发起连接关闭的一方会有一段时间处于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 &quot;lib/common.h&quot;
# define MAXLINE 4096
int main(int argc, char **argv) {
if (argc != 2) {
error(1, 0, &quot;usage: graceclient &lt;IPaddress&gt;&quot;);
}
int socket_fd;
socket_fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server_addr;
bzero(&amp;server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(SERV_PORT);
inet_pton(AF_INET, argv[1], &amp;server_addr.sin_addr);
socklen_t server_len = sizeof(server_addr);
int connect_rt = connect(socket_fd, (struct sockaddr *) &amp;server_addr, server_len);
if (connect_rt &lt; 0) {
error(1, errno, &quot;connect failed &quot;);
}
char send_line[MAXLINE], recv_line[MAXLINE + 1];
int n;
fd_set readmask;
fd_set allreads;
FD_ZERO(&amp;allreads);
FD_SET(0, &amp;allreads);
FD_SET(socket_fd, &amp;allreads);
for (;;) {
readmask = allreads;
int rc = select(socket_fd + 1, &amp;readmask, NULL, NULL, NULL);
if (rc &lt;= 0)
error(1, errno, &quot;select failed&quot;);
if (FD_ISSET(socket_fd, &amp;readmask)) {
n = read(socket_fd, recv_line, MAXLINE);
if (n &lt; 0) {
error(1, errno, &quot;read error&quot;);
} else if (n == 0) {
error(1, 0, &quot;server terminated \n&quot;);
}
recv_line[n] = 0;
fputs(recv_line, stdout);
fputs(&quot;\n&quot;, stdout);
}
if (FD_ISSET(0, &amp;readmask)) {
if (fgets(send_line, MAXLINE, stdin) != NULL) {
if (strncmp(send_line, &quot;shutdown&quot;, 8) == 0) {
FD_CLR(0, &amp;allreads);
if (shutdown(socket_fd, 1)) {
error(1, errno, &quot;shutdown failed&quot;);
}
} else if (strncmp(send_line, &quot;close&quot;, 5) == 0) {
FD_CLR(0, &amp;allreads);
if (close(socket_fd)) {
error(1, errno, &quot;close failed&quot;);
}
sleep(6);
exit(0);
} else {
int i = strlen(send_line);
if (send_line[i - 1] == '\n') {
send_line[i - 1] = 0;
}
printf(&quot;now sending %s\n&quot;, send_line);
size_t rt = write(socket_fd, send_line, strlen(send_line));
if (rt &lt; 0) {
error(1, errno, &quot;write failed &quot;);
}
printf(&quot;send bytes: %zu \n&quot;, 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 &quot;lib/common.h&quot;
static int count;
static void sig_int(int signo) {
printf(&quot;\nreceived %d datagrams\n&quot;, count);
exit(0);
}
int main(int argc, char **argv) {
int listenfd;
listenfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server_addr;
bzero(&amp;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 *) &amp;server_addr, sizeof(server_addr));
if (rt1 &lt; 0) {
error(1, errno, &quot;bind failed &quot;);
}
int rt2 = listen(listenfd, LISTENQ);
if (rt2 &lt; 0) {
error(1, errno, &quot;listen failed &quot;);
}
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 *) &amp;client_addr, &amp;client_len)) &lt; 0) {
error(1, errno, &quot;bind failed &quot;);
}
char message[MAXLINE];
count = 0;
for (;;) {
int n = read(connfd, message, MAXLINE);
if (n &lt; 0) {
error(1, errno, &quot;error read&quot;);
} else if (n == 0) {
error(1, 0, &quot;client closed \n&quot;);
}
message[n] = 0;
printf(&quot;received %d bytes: %s\n&quot;, n, message);
count++;
char send_line[MAXLINE];
sprintf(send_line, &quot;Hi, %s&quot;, message);
sleep(5);
int write_nc = send(connfd, send_line, strlen(send_line), 0);
printf(&quot;send bytes: %zu \n&quot;, write_nc);
if (write_nc &lt; 0) {
error(1, errno, &quot;error write&quot;);
}
}
}
```
服务器端程序的细节也展开解释一下:
第一部分是套接字和连接创建过程:
- 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
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信号。
这回知道你的程序莫名其妙终止的原因了吧。
<img src="https://static001.geekbang.org/resource/image/f2/9a/f283b804c7e33e25a900fedc8c36f09a.png" alt=""><br>
我们可以像这样注册一个信号处理函数对SIGPIPE信号进行处理避免程序莫名退出
```
static void sig_pipe(int signo) {
printf(&quot;\nreceived %d datagrams\n&quot;, 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
Hidata2
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函数。
## 思考题
和往常一样,给你留两道思考题。
第一道题,你可以看到在今天的服务器端程序中,直接调用`exit(0)`完成了FIN报文的发送这是为什么呢为什么不调用close函数或shutdown函数呢
第二道题关于关于信号量处理,今天的程序中,使用的是`SIG_IGN`默认处理,你知道默认处理和自定义函数处理的区别吗?不妨查查资料,了解一下。
欢迎你在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,一起来交流。

View File

@@ -0,0 +1,350 @@
<audio id="audio" title="12 | 连接无效使用Keep-Alive还是应用心跳来检测" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/58/60/586941b4a8a67086dc4151bf00541d60.mp3"></audio>
你好我是盛延敏这里是网络编程实战第12讲欢迎回来。
上一篇文章中我们讲到了如何使用close和shutdown来完成连接的关闭在大多数情况下我们会优选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 &quot;lib/common.h&quot;
#include &quot;message_objecte.h&quot;
#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, &quot;usage: tcpclient &lt;IPaddress&gt;&quot;);
}
int socket_fd;
socket_fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server_addr;
bzero(&amp;server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(SERV_PORT);
inet_pton(AF_INET, argv[1], &amp;server_addr.sin_addr);
socklen_t server_len = sizeof(server_addr);
int connect_rt = connect(socket_fd, (struct sockaddr *) &amp;server_addr, server_len);
if (connect_rt &lt; 0) {
error(1, errno, &quot;connect failed &quot;);
}
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(&amp;allreads);
FD_SET(socket_fd, &amp;allreads);
for (;;) {
readmask = allreads;
int rc = select(socket_fd + 1, &amp;readmask, NULL, NULL, &amp;tv);
if (rc &lt; 0) {
error(1, errno, &quot;select failed&quot;);
}
if (rc == 0) {
if (++heartbeats &gt; KEEP_ALIVE_PROBETIMES) {
error(1, 0, &quot;connection dead\n&quot;);
}
printf(&quot;sending heartbeat #%d\n&quot;, heartbeats);
messageObject.type = htonl(MSG_PING);
rc = send(socket_fd, (char *) &amp;messageObject, sizeof(messageObject), 0);
if (rc &lt; 0) {
error(1, errno, &quot;send failure&quot;);
}
tv.tv_sec = KEEP_ALIVE_INTERVAL;
continue;
}
if (FD_ISSET(socket_fd, &amp;readmask)) {
n = read(socket_fd, recv_line, MAXLINE);
if (n &lt; 0) {
error(1, errno, &quot;read error&quot;);
} else if (n == 0) {
error(1, 0, &quot;server terminated \n&quot;);
}
printf(&quot;received heartbeat, make heartbeats to 0 \n&quot;);
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 &quot;lib/common.h&quot;
#include &quot;message_objecte.h&quot;
static int count;
int main(int argc, char **argv) {
if (argc != 2) {
error(1, 0, &quot;usage: tcpsever &lt;sleepingtime&gt;&quot;);
}
int sleepingTime = atoi(argv[1]);
int listenfd;
listenfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server_addr;
bzero(&amp;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 *) &amp;server_addr, sizeof(server_addr));
if (rt1 &lt; 0) {
error(1, errno, &quot;bind failed &quot;);
}
int rt2 = listen(listenfd, LISTENQ);
if (rt2 &lt; 0) {
error(1, errno, &quot;listen failed &quot;);
}
int connfd;
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
if ((connfd = accept(listenfd, (struct sockaddr *) &amp;client_addr, &amp;client_len)) &lt; 0) {
error(1, errno, &quot;bind failed &quot;);
}
messageObject message;
count = 0;
for (;;) {
int n = read(connfd, (char *) &amp;message, sizeof(messageObject));
if (n &lt; 0) {
error(1, errno, &quot;error read&quot;);
} else if (n == 0) {
error(1, 0, &quot;client closed \n&quot;);
}
printf(&quot;received %d bytes\n&quot;, n);
count++;
switch (ntohl(message.type)) {
case MSG_TYPE1 :
printf(&quot;process MSG_TYPE1 \n&quot;);
break;
case MSG_TYPE2 :
printf(&quot;process MSG_TYPE2 \n&quot;);
break;
case MSG_PING: {
messageObject pong_message;
pong_message.type = MSG_PONG;
sleep(sleepingTime);
ssize_t rc = send(connfd, (char *) &amp;pong_message, sizeof(pong_message), 0);
if (rc &lt; 0)
error(1, errno, &quot;send failure&quot;);
break;
}
default :
error(1, 0, &quot;unknown message type (%d)\n&quot;, 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没有提供系统的保活能力让应用程序可以方便地感知连接的存活但是我们可以在应用程序里灵活地建立这种机制。一般来说这种机制的建立依赖于系统定时器以及恰当的应用层报文协议。比如使用心跳包就是这样一种保持Keep Alive的机制。
## 思考题
和往常一样,我留两道思考题:
你可以看到今天的内容主要是针对TCP的探活那么你觉得这样的方法是否同样适用于UDP呢
第二道题是有人说额外的探活报文占用了有限的带宽对此你是怎么想的呢而且为什么需要多次探活才能决定一个TCP连接是否已经死亡呢
欢迎你在评论区写下你的思考,我会和你一起交流。也欢迎把这篇文章分享给你的朋友或者同事,与他们一起讨论一下这两个问题吧。

View File

@@ -0,0 +1,186 @@
<audio id="audio" title="13 | 小数据包应对之策理解TCP协议中的动态数据传输" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ea/9d/eaa3479a08d67268f51167ca5156fd9d.mp3"></audio>
你好我是盛延敏这里是网络编程实战第13讲欢迎回来。
在上一篇文章里我在应用程序中模拟了TCP Keep-Alive机制完成TCP心跳检测达到发现不活跃连接的目的。在这一讲里我们将从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算法解除请求第二部分的阻止让第二部分得以发送出去服务器端在收到之后进行处理应答同时将第二部分的确认捎带发送出去。
<img src="https://static001.geekbang.org/resource/image/42/eb/42073ad07805783add96ee87aeee8aeb.png" alt=""><br>
你从这张图中可以看到Nagle算法和延时确认组合在一起增大了处理时延实际上两个优化彼此在阻止对方。
从上面的例子可以看到在有些情况下Nagle算法并不适用 比如对时延敏感的应用。
幸运的是我们可以通过对套接字的修改来关闭Nagle算法。
```
int on = 1;
setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, (void *)&amp;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, &quot;usage: tcpclient &lt;IPaddress&gt;&quot;);
}
int socket_fd;
socket_fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server_addr;
bzero(&amp;server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(SERV_PORT);
inet_pton(AF_INET, argv[1], &amp;server_addr.sin_addr);
socklen_t server_len = sizeof(server_addr);
int connect_rt = connect(socket_fd, (struct sockaddr *) &amp;server_addr, server_len);
if (connect_rt &lt; 0) {
error(1, errno, &quot;connect failed &quot;);
}
char buf[128];
struct iovec iov[2];
char *send_one = &quot;hello,&quot;;
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) &lt; 0)
error(1, errno, &quot;writev failure&quot;);
}
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批量发送。
## 思考题
和往常一样,留两道思考题:
针对最后呈现的writev函数你可以查一查Linux下一次性最多允许数组的大小是多少
另外TCP拥塞控制算法是一个非常重要的研究领域请你查阅下最新的有关这方面的研究看看有没有新的发现
欢迎你在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,一起来交流。

View File

@@ -0,0 +1,336 @@
<audio id="audio" title="14丨UDP也可以是“已连接”" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fe/20/fe209d5ee5eee6cab978d3f2887c3920.mp3"></audio>
你好我是盛延敏这里是网络编程实战的第14讲欢迎回来。
在前面的基础篇中我们已经接触到了UDP数据报协议相关的知识在我们的脑海里已经深深印上了“**UDP 等于无连接协议**”的特性。那么看到这一讲的题目你是不是觉得有点困惑没关系和我一起进入“已连接”的UDP的世界回头再看这个标题相信你就会恍然大悟。
## 从一个例子开始
我们先从一个客户端例子开始在这个例子中客户端在UDP套接字上调用connect函数之后将标准输入的字符串发送到服务器端并从服务器端接收处理后的报文。当然向服务器端发送和接收报文是通过调用函数sendto和recvfrom来完成的。
```
#include &quot;lib/common.h&quot;
# define MAXLINE 4096
int main(int argc, char **argv) {
if (argc != 2) {
error(1, 0, &quot;usage: udpclient1 &lt;IPaddress&gt;&quot;);
}
int socket_fd;
socket_fd = socket(AF_INET, SOCK_DGRAM, 0);
struct sockaddr_in server_addr;
bzero(&amp;server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(SERV_PORT);
inet_pton(AF_INET, argv[1], &amp;server_addr.sin_addr);
socklen_t server_len = sizeof(server_addr);
if (connect(socket_fd, (struct sockaddr *) &amp;server_addr, server_len)) {
error(1, errno, &quot;connect failed&quot;);
}
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(&quot;now sending %s\n&quot;, send_line);
size_t rt = sendto(socket_fd, send_line, strlen(send_line), 0, (struct sockaddr *) &amp;server_addr, server_len);
if (rt &lt; 0) {
error(1, errno, &quot;sendto failed&quot;);
}
printf(&quot;send bytes: %zu \n&quot;, rt);
len = 0;
recv_line[0] = 0;
n = recvfrom(socket_fd, recv_line, MAXLINE, 0, reply_addr, &amp;len);
if (n &lt; 0)
error(1, errno, &quot;recvfrom failed&quot;);
recv_line[n] = 0;
fputs(recv_line, stdout);
fputs(&quot;\n&quot;, 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 &quot;lib/common.h&quot;
static int count;
static void recvfrom_int(int signo) {
printf(&quot;\nreceived %d datagrams\n&quot;, 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(&amp;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 *) &amp;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 *) &amp;client_addr, &amp;client_len);
if (n &lt; 0) {
error(1, errno, &quot;recvfrom failed&quot;);
}
message[n] = 0;
printf(&quot;received %d bytes: %s\n&quot;, n, message);
if (connect(socket_fd, (struct sockaddr *) &amp;client_addr, client_len)) {
error(1, errno, &quot;connect failed&quot;);
}
while (strncmp(message, &quot;goodbye&quot;, 7) != 0) {
char send_line[MAXLINE];
sprintf(send_line, &quot;Hi, %s&quot;, message);
size_t rt = send(socket_fd, send_line, strlen(send_line), 0);
if (rt &lt; 0) {
error(1, errno, &quot;send failed &quot;);
}
printf(&quot;send bytes: %zu \n&quot;, rt);
size_t rc = recv(socket_fd, message, MAXLINE, 0);
if (rc &lt; 0) {
error(1, errno, &quot;recv failed&quot;);
}
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 &quot;lib/common.h&quot;
# define MAXLINE 4096
int main(int argc, char **argv) {
if (argc != 2) {
error(1, 0, &quot;usage: udpclient3 &lt;IPaddress&gt;&quot;);
}
int socket_fd;
socket_fd = socket(AF_INET, SOCK_DGRAM, 0);
struct sockaddr_in server_addr;
bzero(&amp;server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(SERV_PORT);
inet_pton(AF_INET, argv[1], &amp;server_addr.sin_addr);
socklen_t server_len = sizeof(server_addr);
if (connect(socket_fd, (struct sockaddr *) &amp;server_addr, server_len)) {
error(1, errno, &quot;connect failed&quot;);
}
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(&quot;now sending %s\n&quot;, send_line);
size_t rt = send(socket_fd, send_line, strlen(send_line), 0);
if (rt &lt; 0) {
error(1, errno, &quot;send failed &quot;);
}
printf(&quot;send bytes: %zu \n&quot;, rt);
recv_line[0] = 0;
n = recv(socket_fd, recv_line, MAXLINE, 0);
if (n &lt; 0)
error(1, errno, &quot;recv failed&quot;);
recv_line[n] = 0;
fputs(recv_line, stdout);
fputs(&quot;\n&quot;, 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绑定本地地址和端口是为了让我们的程序可以快速获取异步错误信息的通知同时也可以获得一定性能上的提升。
## 思考题
在本讲的最后,按照惯例,给你留两个思考题:
1. 可以对一个UDP 套接字进行多次connect操作吗? 你不妨动手试试,看看结果。
1. 如果想使用多播或广播我们应该怎么去使用connect呢
欢迎你在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。

View File

@@ -0,0 +1,250 @@
<audio id="audio" title="15 | 怎么老是出现“地址已经被使用”?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/cf/78/cf702851365c4c253565da3ee626ff78.mp3"></audio>
你好我是盛延敏这里是网络编程实战的第15讲欢迎回来。
上一讲我们讲到UDP也可以像TCP一样使用connect方法以快速获取异步错误的信息。在今天的内容里我们将讨论服务器端程序重启时地址被占用的原因和解决方法。
我们已经知道网络编程中服务器程序需要绑定本地地址和一个端口然后就监听在这个地址和端口上等待客户端连接的到来。在实战中你可能会经常碰到一个问题当服务器端程序重启之后总是碰到“Address in use”的报错信息服务器程序不能很快地重启。那么这个问题是如何产生的我们又该如何避免呢
今天我们就来讲一讲这个“地址已经被使用”的问题。
## 从例子开始
为了引入讨论我们从之前讲过的一个TCP服务器端程序开始说起
```
static int count;
static void sig_int(int signo) {
printf(&quot;\nreceived %d datagrams\n&quot;, count);
exit(0);
}
int main(int argc, char **argv) {
int listenfd;
listenfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server_addr;
bzero(&amp;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 *) &amp;server_addr, sizeof(server_addr));
if (rt1 &lt; 0) {
error(1, errno, &quot;bind failed &quot;);
}
int rt2 = listen(listenfd, LISTENQ);
if (rt2 &lt; 0) {
error(1, errno, &quot;listen failed &quot;);
}
signal(SIGPIPE, SIG_IGN);
int connfd;
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
if ((connfd = accept(listenfd, (struct sockaddr *) &amp;client_addr, &amp;client_len)) &lt; 0) {
error(1, errno, &quot;bind failed &quot;);
}
char message[MAXLINE];
count = 0;
for (;;) {
int n = read(connfd, message, MAXLINE);
if (n &lt; 0) {
error(1, errno, &quot;error read&quot;);
} else if (n == 0) {
error(1, 0, &quot;client closed \n&quot;);
}
message[n] = 0;
printf(&quot;received %d bytes: %s\n&quot;, n, message);
count++;
}
}
```
这个服务器端程序绑定到一个本地端口使用的是通配地址ANY当连接建立之后从该连接中读取输入的字符流。
启动服务器之后我们使用Telnet登录这个服务器并在屏幕上输入一些字符例如networkgood。
和我们期望的一样服务器端打印出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。如果你对此有点淡忘没有关系我在下面放了一张图希望会唤起你的记忆。
<img src="https://static001.geekbang.org/resource/image/94/5f/945c60ae06d282dcc22ad3b868f1175f.png" alt=""><br>
如果我们此时使用netstat去查看服务器程序所在主机的TIME_WAIT的状态连接你会发现有一个服务器程序生成的TCP连接当前正处于TIME_WAIT状态。这里9527是本地监听端口36650是telnet客户端端口。当然了Telnet客户端端口每次也会不尽相同。
<img src="https://static001.geekbang.org/resource/image/51/e1/5127adf94e564c13d6be86460d3317e1.png" alt=""><br>
通过服务器端发起的关闭连接操作引起了一个已有的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, &amp;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(&amp;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, &amp;on, sizeof(on));
int rt1 = bind(listenfd, (struct sockaddr *) &amp;server_addr, sizeof(server_addr));
if (rt1 &lt; 0) {
error(1, errno, &quot;bind failed &quot;);
}
int rt2 = listen(listenfd, LISTENQ);
if (rt2 &lt; 0) {
error(1, errno, &quot;listen failed &quot;);
}
signal(SIGPIPE, SIG_IGN);
int connfd;
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
if ((connfd = accept(listenfd, (struct sockaddr *) &amp;client_addr, &amp;client_len)) &lt; 0) {
error(1, errno, &quot;bind failed &quot;);
}
char message[MAXLINE];
count = 0;
for (;;) {
int n = read(connfd, message, MAXLINE);
if (n &lt; 0) {
error(1, errno, &quot;error read&quot;);
} else if (n == 0) {
error(1, 0, &quot;client closed \n&quot;);
}
message[n] = 0;
printf(&quot;received %d bytes: %s\n&quot;, 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讲](https://time.geekbang.org/column/article/125806)中我们提到过一个叫做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套接字选项**。这不会产生危害,相反,它会帮助我们在很快时间内重启服务端程序,而这一点恰恰是很多场景所需要的。
## 思考题
跟往常一样,给你布置两道思考题:
第一道之前我们看到的例子都是对TCP套接字设置SO_REUSEADDR套接字选项你知道吗我们也可以对UDP设置SO_REUSEADDR套接字选项。那么问题来了对UDP来说设置SO_REUSEADDR套接字选项有哪些场景和好处呢
第二道在服务器端程序中设置SO_REUSEADDR套接字选项时需要在bind函数之前对监听字进行设置想一想为什么不是对已连接的套接字进行设置呢
欢迎你在评论区写下你的思考,我会和你一起讨论交流,也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。

View File

@@ -0,0 +1,391 @@
<audio id="audio" title="16 | 如何理解TCP的“流”" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d1/cb/d1471106b130f34c1fce97b4f1f312cb.mp3"></audio>
你好我是盛延敏这里是网络编程实战第16讲欢迎回来。
上一讲我们讲到了使用SO_REUSEADDR套接字选项可以让服务器满足快速重启的需求。在这一讲里我们回到数据的收发这个主题谈一谈如何理解TCP的数据流特性。
## TCP是一种流式协议
在前面的章节中,我们讲的都是单个客户端-服务器的例子可能会给你造成一种错觉好像TCP是一种应答形式的数据传输过程比如发送端一次发送network和program这样的报文在前面的例子中我们看到的结果基本是这样的
发送端network ----&gt; 接收端回应Hi, network
发送端program -----&gt; 接收端回应Hi, program
这其实是一个假象,之所以会这样,是因为网络条件比较好,而且发送的数据也比较少。
为了让大家理解TCP数据是流式的这个特性我们分别从发送端和接收端来阐述。
我们知道在发送端当我们调用send函数完成数据“发送”以后数据并没有被真正从网络上发送出去只是从应用程序拷贝到了操作系统内核协议栈中至于什么时候真正被发送取决于发送窗口、拥塞窗口以及当前发送缓冲区的大小等条件。也就是说我们不能假设每次send调用发送的数据都会作为一个整体完整地被发送出去。
如果我们考虑实际网络传输过程中的各种影响假设发送端陆续调用send函数先后发送network和program报文那么实际的发送很有可能是这个样子的。
第一种情况一次性将network和program在一个TCP分组中发送出去像这样
```
...xxxnetworkprogramxxx...
```
第二种情况program的部分随network在一个TCP分组中发送出去像这样
TCP分组1
```
...xxxxxnetworkpro
```
TCP分组2
```
gramxxxxxxxxxx...
```
第三种情况network的一部分随TCP分组被发送出去另一部分和program一起随另一个TCP分组发送出去像这样。
TCP分组1
```
...xxxxxxxxxxxnet
```
TCP分组2
```
workprogramxxx...
```
实际上类似的组合可以枚举出无数种。不管是哪一种核心的问题就是我们不知道network和program这两个报文是如何进行TCP分组传输的。换言之我们在发送数据的时候不应该假设“数据流和TCP分组是一种映射关系”。就好像在前面我们似乎觉得network这个报文一定对应一个TCP分组这是完全不正确的。
如果我们再来看客户端,数据流的特征更明显。
我们知道接收端缓冲区保留了没有被取走的数据随着应用程序不断从接收端缓冲区读出数据接收端缓冲区就可以容纳更多新的数据。如果我们使用recv从接收端缓冲区读取数据发送端缓冲区的数据是以字节流的方式存在的无论发送端如何构造TCP分组接收端最终收到的字节流总是像下面这样
```
xxxxxxxxxxxxxxxxxnetworkprogramxxxxxxxxxxxx
```
关于接收端字节流,有两点需要注意:
第一这里netwrok和program的顺序肯定是会保持的也就是说先调用send函数发送的字节总在后调用send函数发送字节的前面这个是由TCP严格保证的
第二如果发送过程中有TCP分组丢失但是其后续分组陆续到达那么TCP协议栈会缓存后续分组直到前面丢失的分组到达最终形成可以被应用程序读取的数据流。
## 网络字节排序
我们知道计算机最终保存和传输用的都是0101这样的二进制数据字节流在网络上的传输也是通过二进制来完成的。
从二进制到字节是通过编码完成的比如著名的ASCII编码通过一个字节8个比特对常用的西方字母进行了编码。
这里有一个有趣的问题如果需要传输数字比如0x0201对应的二进制为0000001000000001那么两个字节的数据到底是先传0x01还是相反
<img src="https://static001.geekbang.org/resource/image/79/e6/79ada2f154205f5170cf8e69bf9f59e6.png" alt=""><br>
在计算机发展的历史上对于如何存储这个数据没有形成标准。比如这里讲到的问题不同的系统就会有两种存法一种是将0x02高字节存放在起始地址这个叫做**大端字节序**Big-Endian。另一种相反将0x01低字节存放在起始地址这个叫做**小端字节序**Little-Endian
但是在网络传输中必须保证双方都用同一种标准来表达这就好比我们打电话时说的是同一种语言否则双方不能顺畅地沟通。这个标准就涉及到了网络字节序的选择问题对于网络字节序必须二选一。我们可以看到网络协议使用的是大端字节序我个人觉得大端字节序比较符合人类的思维习惯你可以想象手写一个多位数字从开始往小位写自然会先写大位比如写12, 1234这个样子。
为了保证网络字节序一致POSIX标准提供了如下的转换函数
```
uint16_t htons (uint16_t hostshort)
uint16_t ntohs (uint16_t netshort)
uint32_t htonl (uint32_t hostlong)
uint32_t ntohl (uint32_t netlong)
```
这里函数中的n代表的就是networkh代表的是hosts表示的是shortl表示的是long分别表示16位和32位的整数。
这些函数可以帮助我们在主机host和网络network的格式间灵活转换。当使用这些函数时我们并不需要关心主机到底是什么样的字节顺序只要使用函数给定值进行网络字节序和主机字节序的转换就可以了。
你可以想象,如果碰巧我们的系统本身是大端字节序,和网络字节序一样,那么使用上述所有的函数进行转换的时候,结果都仅仅是一个空实现,直接返回。
比如这样:
```
# if __BYTE_ORDER == __BIG_ENDIAN
/* The host byte order is the same as network byte order,
so these functions are all just identity. */
# define ntohl(x) (x)
# define ntohs(x) (x)
# define htonl(x) (x)
# define htons(x) (x)
```
## 报文读取和解析
应该看到,报文是以字节流的形式呈现给应用程序的,那么随之而来的一个问题就是,应用程序如何解读字节流呢?
这就要说到报文格式和解析了。报文格式实际上定义了字节的组织形式,发送端和接收端都按照统一的报文格式进行数据传输和解析,这样就可以保证彼此能够完成交流。
只有知道了报文格式,接收端才能针对性地进行报文读取和解析工作。
报文格式最重要的是如何确定报文的边界。常见的报文格式有两种方法,一种是发送端把要发送的报文长度预先通过报文告知给接收端;另一种是通过一些特殊的字符来进行边界的划分。
## 显式编码报文长度
### 报文格式
下面我们来看一个例子,这个例子是把要发送的报文长度预先通过报文告知接收端:
<img src="https://static001.geekbang.org/resource/image/33/15/33805892d57843a1f22830d8636e1315.png" alt=""><br>
由图可以看出这个报文的格式很简单首先4个字节大小的消息长度其目的是将真正发送的字节流的大小显式通过报文告知接收端接下来是4个字节大小的消息类型而真正需要发送的数据则紧随其后。
### 发送报文
发送端的程序如下:
```
int main(int argc, char **argv) {
if (argc != 2) {
error(1, 0, &quot;usage: tcpclient &lt;IPaddress&gt;&quot;);
}
int socket_fd;
socket_fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server_addr;
bzero(&amp;server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(SERV_PORT);
inet_pton(AF_INET, argv[1], &amp;server_addr.sin_addr);
socklen_t server_len = sizeof(server_addr);
int connect_rt = connect(socket_fd, (struct sockaddr *) &amp;server_addr, server_len);
if (connect_rt &lt; 0) {
error(1, errno, &quot;connect failed &quot;);
}
struct {
u_int32_t message_length;
u_int32_t message_type;
char buf[128];
} message;
int n;
while (fgets(message.buf, sizeof(message.buf), stdin) != NULL) {
n = strlen(message.buf);
message.message_length = htonl(n);
message.message_type = 1;
if (send(socket_fd, (char *) &amp;message, sizeof(message.message_length) + sizeof(message.message_type) + n, 0) &lt;
0)
error(1, errno, &quot;send failure&quot;);
}
exit(0);
}
```
程序的1-20行是常规的创建套接字和地址建立连接的过程。我们重点往下看21-25行就是图示的报文格式转化为结构体29-37行从标准输入读入数据分别对消息长度、类型进行了初始化注意这里使用了htonl函数将字节大小转化为了网络字节顺序这一点很重要。最后我们看到23行实际发送的字节流大小为消息长度4字节加上消息类型4字节以及标准输入的字符串大小。
### 解析报文:程序
下面给出的是服务器端的程序,和客户端不一样的是,服务器端需要对报文进行解析。
```
static int count;
static void sig_int(int signo) {
printf(&quot;\nreceived %d datagrams\n&quot;, count);
exit(0);
}
int main(int argc, char **argv) {
int listenfd;
listenfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server_addr;
bzero(&amp;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, &amp;on, sizeof(on));
int rt1 = bind(listenfd, (struct sockaddr *) &amp;server_addr, sizeof(server_addr));
if (rt1 &lt; 0) {
error(1, errno, &quot;bind failed &quot;);
}
int rt2 = listen(listenfd, LISTENQ);
if (rt2 &lt; 0) {
error(1, errno, &quot;listen failed &quot;);
}
signal(SIGPIPE, SIG_IGN);
int connfd;
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
if ((connfd = accept(listenfd, (struct sockaddr *) &amp;client_addr, &amp;client_len)) &lt; 0) {
error(1, errno, &quot;bind failed &quot;);
}
char buf[128];
count = 0;
while (1) {
int n = read_message(connfd, buf, sizeof(buf));
if (n &lt; 0) {
error(1, errno, &quot;error read message&quot;);
} else if (n == 0) {
error(1, 0, &quot;client closed \n&quot;);
}
buf[n] = 0;
printf(&quot;received %d bytes: %s\n&quot;, n, buf);
count++;
}
exit(0);
}
```
这个程序1-41行创建套接字等待连接建立部分和前面基本一致。我们重点看42-55行的部分。45-55行循环处理字节流调用read_message函数进行报文解析工作并把报文的主体通过标准输出打印出来。
### 解析报文readn函数
在了解read_message工作原理之前我们先来看第5讲就引入的一个函数readn。这里一定要强调的是readn函数的语义**读取报文预设大小的字节**readn调用会一直循环尝试读取预设大小的字节如果接收缓冲区数据空readn函数会阻塞在那里直到有数据到达。
```
size_t readn(int fd, void *buffer, size_t length) {
size_t count;
ssize_t nread;
char *ptr;
ptr = buffer;
count = length;
while (count &gt; 0) {
nread = read(fd, ptr, count);
if (nread &lt; 0) {
if (errno == EINTR)
continue;
else
return (-1);
} else if (nread == 0)
break; /* EOF */
count -= nread;
ptr += nread;
}
return (length - count); /* return &gt;= 0 */
}
```
readn函数中使用count来表示还需要读取的字符数如果count一直大于0说明还没有满足预设的字符大小循环就会继续。第9行通过read函数来服务最多count个字符。11-17行针对返回值进行出错判断其中返回值为0的情形是EOF表示对方连接终止。19-20行要读取的字符数减去这次读到的字符数同时移动缓冲区指针这样做的目的是为了确认字符数是否已经读取完毕。
### 解析报文: read_message函数
有了readn函数作为基础我们再看一下read_message对报文的解析处理
```
size_t read_message(int fd, char *buffer, size_t length) {
u_int32_t msg_length;
u_int32_t msg_type;
int rc;
rc = readn(fd, (char *) &amp;msg_length, sizeof(u_int32_t));
if (rc != sizeof(u_int32_t))
return rc &lt; 0 ? -1 : 0;
msg_length = ntohl(msg_length);
rc = readn(fd, (char *) &amp;msg_type, sizeof(msg_type));
if (rc != sizeof(u_int32_t))
return rc &lt; 0 ? -1 : 0;
if (msg_length &gt; length) {
return -1;
}
rc = readn(fd, buffer, msg_length);
if (rc != msg_length)
return rc &lt; 0 ? -1 : 0;
return rc;
}
```
在这个函数中第6行通过调用readn函数获取4个字节的消息长度数据紧接着第11行通过调用readn函数获取4个字节的消息类型数据。第15行判断消息的长度是不是太大如果大到本地缓冲区不能容纳则直接返回错误第19行调用readn一次性读取已知长度的消息体。
### 实验
我们依次启动作为报文解析的服务器一端,以及作为报文发送的客户端。我们看到,每次客户端发送的报文都可以被服务器端解析出来,在标准输出上的结果验证了这一点。
```
$./streamserver
received 8 bytes: network
received 5 bytes: good
```
```
$./streamclient
network
good
```
## 特殊字符作为边界
前面我提到了两种报文格式另外一种报文格式就是通过设置特殊字符作为报文边界。HTTP是一个非常好的例子。
<img src="https://static001.geekbang.org/resource/image/6d/5a/6d91c7c2a0224f5d4bad32a0f488765a.png" alt=""><br>
HTTP通过设置回车符、换行符作为HTTP报文协议的边界。
下面的read_line函数就是在尝试读取一行数据也就是读到回车符`\r`,或者读到回车换行符`\r\n`为止。这个函数每次尝试读取一个字节第9行如果读到了回车符`\r`接下来在11行的“观察”下看有没有换行符如果有就在第12行读取这个换行符如果没有读到回车符就在第16-17行将字符放到缓冲区并移动指针。
```
int read_line(int fd, char *buf, int size) {
int i = 0;
char c = '\0';
int n;
while ((i &lt; size - 1) &amp;&amp; (c != '\n')) {
n = recv(fd, &amp;c, 1, 0);
if (n &gt; 0) {
if (c == '\r') {
n = recv(fd, &amp;c, 1, MSG_PEEK);
if ((n &gt; 0) &amp;&amp; (c == '\n'))
recv(fd, &amp;c, 1, 0);
else
c = '\n';
}
buf[i] = c;
i++;
} else
c = '\n';
}
buf[i] = '\0';
return (i);
}
```
## 总结
和我们预想的不太一样TCP数据流特性决定了字节流本身是没有边界的一般我们通过显式编码报文长度的方式以及选取特殊字符区分报文边界的方式来进行报文格式的设计。而对报文解析的工作就是要在知道报文格式的情况下有效地对报文信息进行还原。
## 思考题
和往常一样,这里给你留两道思考题,供你消化今天的内容。
第一道题关于HTTP的报文格式我们看到既要处理只有回车的情景也要处理同时有回车和换行的情景你知道造成这种情况的原因是什么吗
第二道题是我们这里讲到的报文格式和TCP分组的报文格式有什么区别和联系吗
欢迎你在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,与他们一起交流一下这两个问题吧。

View File

@@ -0,0 +1,275 @@
<audio id="audio" title="17 | TCP并不总是“可靠”的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ae/58/ae172aaf12d8df6378e52eee03aaa658.mp3"></audio>
你好我是盛延敏这里是网络编程实战第17讲欢迎回来。
在前面一讲中我们讲到如何理解TCP数据流的本质进而引出了报文格式和解析。在这一讲里我们讨论通过如何增强读写操作以处理各种“不可靠”的场景。
## TCP是可靠的
你可能会认为TCP是一种可靠的协议这种可靠体现在端到端的通信上。这似乎给我们带来了一种错觉从发送端来看应用程序通过调用send函数发送的数据流总能可靠地到达接收端而从接收端来看总是可以把对端发送的数据流完整无损地传递给应用程序来处理。
事实上如果我们对TCP传输环节进行详细的分析你就会沮丧地发现上述论断是不正确的。
前面我们已经了解发送端通过调用send函数之后数据流并没有马上通过网络传输出去而是存储在套接字的发送缓冲区中由网络协议栈决定何时发送、如何发送。当对应的数据发送给接收端接收端回应ACK存储在发送缓冲区的这部分数据就可以删除了但是发送端并无法获取对应数据流的ACK情况也就是说发送端没有办法判断对端的接收方是否已经接收发送的数据流如果需要知道这部分信息就必须在应用层自己添加处理逻辑例如显式的报文确认机制。
从接收端来说也没有办法保证ACK过的数据部分可以被应用程序处理因为数据需要接收端程序从接收缓冲区中拷贝可能出现的状况是已经ACK的数据保存在接收端缓冲区中接收端处理程序突然崩溃了这部分数据就没有办法被应用程序继续处理。
你有没有发现TCP协议实现并没有提供给上层应用程序过多的异常处理细节或者说TCP协议反映链路异常的能力偏弱这其实是有原因的。要知道TCP诞生之初就是为美国国防部服务的考虑到军事作战的实际需要TCP不希望暴露更多的异常细节而是能够以无人值守、自我恢复的方式运作。
TCP连接建立之后能感知TCP链路的方式是有限的一种是以read为核心的读操作另一种是以write为核心的写操作。接下来我们就看下如何通过读写操作来感知异常情况以及对应的处理方式。
## 故障模式总结
在实际情景中,我们会碰到各种异常的情况。在这里我把这几种异常情况归结为两大类:
<img src="https://static001.geekbang.org/resource/image/39/af/39b060fa90628db95fd33305dc6fc7af.png" alt=""><br>
第一类是对端无FIN包发送出来的情况第二类是对端有FIN包发送出来。而这两大类情况又可以根据应用程序的场景细分接下来我们详细讨论。
## 网络中断造成的对端无FIN包
很多原因都会造成网络中断在这种情况下TCP程序并不能及时感知到异常信息。除非网络中的其他设备如路由器发出一条ICMP报文说明目的网络或主机不可达这个时候通过read或write调用就会返回Unreachable的错误。
可惜大多数时候并不是如此在没有ICMP报文的情况下TCP程序并不能理解感应到连接异常。如果程序是阻塞在read调用上那么很不幸程序无法从异常中恢复。这显然是非常不合理的不过我们可以通过给read操作设置超时来解决在接下来的第18讲中我会讲到具体的方法。
如果程序先调用了write操作发送了一段数据流接下来阻塞在read调用上结果会非常不同。Linux系统的TCP协议栈会不断尝试将发送缓冲区的数据发送出去大概在重传12次、合计时间约为9分钟之后协议栈会标识该连接异常这时阻塞的read调用会返回一条TIMEOUT的错误信息。如果此时程序还执着地往这条连接写数据写操作会立即失败返回一个SIGPIPE信号给应用程序。
## 系统崩溃造成的对端无FIN包
当系统突然崩溃如断电时网络连接上来不及发出任何东西。这里和通过系统调用杀死应用程序非常不同的是没有任何FIN包被发送出来。
这种情况和网络中断造成的结果非常类似在没有ICMP报文的情况下TCP程序只能通过read和write调用得到网络连接异常的信息超时错误是一个常见的结果。
不过还有一种情况需要考虑那就是系统在崩溃之后又重启当重传的TCP分组到达重启后的系统由于系统中没有该TCP分组对应的连接数据系统会返回一个RST重置分节TCP程序通过read或write调用可以分别对RST进行错误处理。
如果是阻塞的read调用会立即返回一个错误错误信息为连接重置Connection Reset
如果是一次write操作也会立即失败应用程序会被返回一个SIGPIPE信号。
## 对端有FIN包发出
对端如果有FIN包发出可能的场景是对端调用了close或shutdown显式地关闭了连接也可能是对端应用程序崩溃操作系统内核代为清理所发出的。从应用程序角度上看无法区分是哪种情形。
阻塞的read操作在完成正常接收的数据读取之后FIN包会通过返回一个EOF来完成通知此时read调用返回值为0。这里强调一点收到FIN包之后read操作不会立即返回。你可以这样理解收到FIN包相当于往接收缓冲区里放置了一个EOF符号之前已经在接收缓冲区的有效数据不会受到影响。
为了展示这些特性,我分别编写了服务器端和客户端程序。
```
//服务端程序
int main(int argc, char **argv) {
int connfd;
char buf[1024];
connfd = tcp_server(SERV_PORT);
for (;;) {
int n = read(connfd, buf, 1024);
if (n &lt; 0) {
error(1, errno, &quot;error read&quot;);
} else if (n == 0) {
error(1, 0, &quot;client closed \n&quot;);
}
sleep(5);
int write_nc = send(connfd, buf, n, 0);
printf(&quot;send bytes: %zu \n&quot;, write_nc);
if (write_nc &lt; 0) {
error(1, errno, &quot;error write&quot;);
}
}
exit(0);
}
```
服务端程序是一个简单的应答程序在收到数据流之后回显给客户端在此之前休眠5秒以便完成后面的实验验证。
客户端程序从标准输入读入,将读入的字符串传输给服务器端:
```
//客户端程序
int main(int argc, char **argv) {
if (argc != 2) {
error(1, 0, &quot;usage: reliable_client01 &lt;IPaddress&gt;&quot;);
}
int socket_fd = tcp_client(argv[1], SERV_PORT);
char buf[128];
int len;
int rc;
while (fgets(buf, sizeof(buf), stdin) != NULL) {
len = strlen(buf);
rc = send(socket_fd, buf, len, 0);
if (rc &lt; 0)
error(1, errno, &quot;write failed&quot;);
rc = read(socket_fd, buf, sizeof(buf));
if (rc &lt; 0)
error(1, errno, &quot;read failed&quot;);
else if (rc == 0)
error(1, 0, &quot;peer connection closed\n&quot;);
else
fputs(buf, stdout);
}
exit(0);
}
```
### read直接感知FIN包
我们依次启动服务器端和客户端程序在客户端输入good字符之后迅速结束掉服务器端程序这里需要赶在服务器端从睡眠中苏醒之前杀死服务器程序。
屏幕上打印出peer connection closed。客户端程序正常退出。
```
$./reliable_client01 127.0.0.1
$ good
$ peer connection closed
```
这说明客户端程序通过read调用感知到了服务端发送的FIN包于是正常退出了客户端程序。
<img src="https://static001.geekbang.org/resource/image/b0/ec/b0922e1b1824f1e4735f2788eb3527ec.png" alt=""><br>
注意如果我们的速度不够快导致服务器端从睡眠中苏醒并成功将报文发送出来后客户端会正常显示此时我们停留等待标准输入。如果不继续通过read或write操作对套接字进行读写是无法感知服务器端已经关闭套接字这个事实的。
### 通过write产生RSTread调用感知RST
这一次我们仍然依次启动服务器端和客户端程序在客户端输入bad字符之后等待一段时间直到客户端正确显示了服务端的回应“bad”字符之后再杀死服务器程序。客户端再次输入bad2这时屏幕上打印出”peer connection closed“。
这是这个案例的屏幕输出和时序图。
```
$./reliable_client01 127.0.0.1
$bad
$bad
$bad2
$peer connection closed
```
<img src="https://static001.geekbang.org/resource/image/a9/f2/a95d3b87a9a93421774d7aeade8efbf2.png" alt=""><br>
在很多书籍和文章中对这个程序的解读是收到FIN包的客户端继续合法地向服务器端发送数据服务器端在无法定位该TCP连接信息的情况下发送了RST信息当程序调用read操作时内核会将RST错误信息通知给应用程序。这是一个典型的write操作造成异常再通过read操作来感知异常的样例。
不过我在Linux 4.4内核上实验这个程序多次的结果都是内核正常将EOF信息通知给应用程序而不是RST错误信息。
我又在Max OS 10.13.6上尝试这个程序read操作可以返回RST异常信息。输出和时序图也已经给出。
```
$./reliable_client01 127.0.0.1
$bad
$bad
$bad2
$read failed: Connection reset by peer (54)
```
### 向一个已关闭连接连续写最终导致SIGPIPE
为了模拟这个过程,我对服务器端程序和客户端程序都做了如下修改。
```
nt main(int argc, char **argv) {
int connfd;
char buf[1024];
int time = 0;
connfd = tcp_server(SERV_PORT);
while (1) {
int n = read(connfd, buf, 1024);
if (n &lt; 0) {
error(1, errno, &quot;error read&quot;);
} else if (n == 0) {
error(1, 0, &quot;client closed \n&quot;);
}
time++;
fprintf(stdout, &quot;1K read for %d \n&quot;, time);
usleep(1000);
}
exit(0);
}
```
服务器端每次读取1K数据后休眠1秒以模拟处理数据的过程。
客户端程序在第8行注册了SIGPIPE的信号处理程序在第14-22行客户端程序一直循环发送数据流。
```
int main(int argc, char **argv) {
if (argc != 2) {
error(1, 0, &quot;usage: reliable_client02 &lt;IPaddress&gt;&quot;);
}
int socket_fd = tcp_client(argv[1], SERV_PORT);
signal(SIGPIPE, SIG_IGN);
char *msg = &quot;network programming&quot;;
ssize_t n_written;
int count = 10000000;
while (count &gt; 0) {
n_written = send(socket_fd, msg, strlen(msg), 0);
fprintf(stdout, &quot;send into buffer %ld \n&quot;, n_written);
if (n_written &lt;= 0) {
error(1, errno, &quot;send error&quot;);
return -1;
}
count--;
}
return 0;
}
```
如果在服务端读取数据并处理过程中,突然杀死服务器进程,我们会看到客**户端很快也会退出**并在屏幕上打印出“Connection reset by peer”的提示。
```
$./reliable_client02 127.0.0.1
$send into buffer 5917291
$send into buffer -1
$send: Connection reset by peer
```
这是因为服务端程序被杀死之后操作系统内核会做一些清理的事情为这个套接字发送一个FIN包但是客户端在收到FIN包之后没有read操作还是会继续往这个套接字写入数据。这是因为根据TCP协议连接是双向的收到对方的FIN包只意味着**对方不会再发送任何消息**。 在一个双方正常关闭的流程中收到FIN包的一端将剩余数据发送给对面通过一次或多次write然后关闭套接字。
当数据到达服务器端时操作系统内核发现这是一个指向关闭的套接字会再次向客户端发送一个RST包对于发送端而言如果此时再执行write操作立即会返回一个RST错误信息。
你可以看到针对这个全过程的一张描述图,你可以参考这张图好好理解一下这个过程。
<img src="https://static001.geekbang.org/resource/image/eb/42/ebf533a453573b85ff03a46103fc5b42.png" alt=""><br>
以上是在Linux 4.4内核上测试的结果。
在很多书籍和文章中对这个实验的期望结果不是这样的。大部分的教程是这样说的在第二次write操作时由于服务器端无法查询到对应的TCP连接信息于是发送了一个RST包给客户端客户端第二次操作时应用程序会收到一个SIGPIPE信号。如果不捕捉这个信号应用程序会在毫无征兆的情况下直接退出。
我在Max OS 10.13.6上尝试这个程序,得到的结果确实如此。你可以看到屏幕显示和时序图。
```
#send into buffer 19
#send into buffer -1
#send error: Broken pipe (32)
```
这说明Linux4.4的实现和类BSD的实现已经非常不一样了。限于时间的关系我没有仔细对比其他版本的Linux还不清楚是新的内核特性但有一点是可以肯定的我们需要记得为SIGPIPE注册处理函数通过write操作感知RST的错误信息这样可以保证我们的应用程序在Linux 4.4和Mac OS上都能正常处理异常。
## 总结
在这一讲中我们意识到TCP并不是那么“可靠”的。我把故障分为两大类一类是对端无FIN包需要通过巡检或超时来发现另一类是对端有FIN包发出需要通过增强read或write操作的异常处理帮助我们发现此类异常。
## 思考题
和往常一样,给大家布置两道思考题。
第一道你不妨在你的Linux系统中重新模拟一下今天文章里的实验看看运行结果是否和我的一样。欢迎你把内核版本和结果贴在评论里。
第二道题是,如果服务器主机正常关闭,已连接的程序会发生什么呢?
你不妨思考一下这两道题,欢迎你在评论区写下你的模拟结果和思考,我会和你一起交流,也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。

View File

@@ -0,0 +1,335 @@
<audio id="audio" title="18 | 防人之心不可无:检查数据的有效性" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6d/c4/6d1debeb6162a55ea86c0e68df7c6dc4.mp3"></audio>
你好我是盛延敏这里是网络编程实战第18讲欢迎回来。
在前面一讲中,我们仔细分析了引起故障的原因,并且已经知道为了应对可能出现的各种故障,必须在程序中做好防御工作。
在这一讲里,我们继续前面的讨论,看一看为了增强程序的健壮性,我们还需要准备什么。
## 对端的异常状况
在前面的第11讲以及第17讲中我们已经初步接触过一些防范对端异常的方法比如通过read等调用时可以通过对EOF的判断随时防范对方程序崩溃。
```
int nBytes = recv(connfd, buffer, sizeof(buffer), 0);
if (nBytes == -1) {
error(1, errno, &quot;error read message&quot;);
} else if (nBytes == 0) {
error(1, 0, &quot;client closed \n&quot;);
}
```
你可以看到这一个程序中的第4行当调用read函数返回0字节时实际上就是操作系统内核返回EOF的一种反映。如果是服务器端同时处理多个客户端连接一般这里会调用shutdown关闭连接的这一端。
上一讲也讲到了不是每种情况都可以通过读操作来感知异常比如服务器完全崩溃或者网络中断的情况下此时如果是阻塞套接字会一直阻塞在read等调用上没有办法感知套接字的异常。
其实有几种办法来解决这个问题。
第一个办法是给套接字的read操作设置超时如果超过了一段时间就认为连接已经不存在。具体的代码片段如下
```
struct timeval tv;
tv.tv_sec = 5;
tv.tv_usec = 0;
setsockopt(connfd, SOL_SOCKET, SO_RCVTIMEO, (const char *) &amp;tv, sizeof tv);
while (1) {
int nBytes = recv(connfd, buffer, sizeof(buffer), 0);
if (nBytes == -1) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
printf(&quot;read timeout\n&quot;);
onClientTimeout(connfd);
} else {
error(1, errno, &quot;error read message&quot;);
}
} else if (nBytes == 0) {
error(1, 0, &quot;client closed \n&quot;);
}
...
}
```
这个代码片段在第4行调用setsockopt函数设置了套接字的读操作超时超时时间为在第1-3行设置的5秒当然在这里这个时间值是“拍脑袋”设置的比较科学的设置方法是通过一定的统计之后得到一个比较合理的值。关键之处在读操作返回异常的第9-11行根据出错信息是`EAGAIN`或者`EWOULDBLOCK`,判断出超时,转而调用`onClientTimeout`函数来进行处理。
这个处理方式虽然比较简单却很实用很多FTP服务器端就是这么设计的。连接这种FTP服务器之后如果FTP的客户端没有续传的功能在碰到网络故障或服务器崩溃时就会挂断。
第二个办法是第12讲中提到的办法添加对连接是否正常的检测。如果连接不正常需要从当前read阻塞中返回并处理。
还有一个办法前面第12讲也提到过那就是利用多路复用技术自带的超时能力来完成对套接字I/O的检查如果超过了预设的时间就进入异常处理。
```
struct timeval tv;
tv.tv_sec = 5;
tv.tv_usec = 0;
FD_ZERO(&amp;allreads);
FD_SET(socket_fd, &amp;allreads);
for (;;) {
readmask = allreads;
int rc = select(socket_fd + 1, &amp;readmask, NULL, NULL, &amp;tv);
if (rc &lt; 0) {
error(1, errno, &quot;select failed&quot;);
}
if (rc == 0) {
printf(&quot;read timeout\n&quot;);
onClientTimeout(socket_fd);
}
...
}
```
这段代码使用了select多路复用技术来对套接字进行I/O事件的轮询程序的13行是到达超时后的处理逻辑调用`onClientTimeout`函数来进行超时后的处理。
## 缓冲区处理
一个设计良好的网络程序,应该可以在随机输入的情况下表现稳定。不仅是这样,随着互联网的发展,网络安全也愈发重要,我们编写的网络程序能不能在黑客的刻意攻击之下表现稳定,也是一个重要考量因素。
很多黑客程序会针对性地构建出一定格式的网络协议包导致网络程序产生诸如缓冲区溢出、指针异常的后果影响程序的服务能力严重的甚至可以夺取服务器端的控制权随心所欲地进行破坏活动比如著名的SQL注入就是通过针对性地构造出SQL语句完成对数据库敏感信息的窃取。
所以,在网络程序的编写过程中,我们需要时时刻刻提醒自己面对的是各种复杂异常的场景,甚至是别有用心的攻击者,保持“防人之心不可无”的警惕。
那么程序都有可能出现哪几种漏洞呢?
### 第一个例子
```
char Response[] = &quot;COMMAND OK&quot;;
char buffer[128];
while (1) {
int nBytes = recv(connfd, buffer, sizeof(buffer), 0);
if (nBytes == -1) {
error(1, errno, &quot;error read message&quot;);
} else if (nBytes == 0) {
error(1, 0, &quot;client closed \n&quot;);
}
buffer[nBytes] = '\0';
if (strcmp(buffer, &quot;quit&quot;) == 0) {
printf(&quot;client quit\n&quot;);
send(socket, Response, sizeof(Response), 0);
}
printf(&quot;received %d bytes: %s\n&quot;, nBytes, buffer);
}
```
这段代码从连接套接字中获取字节流并且判断了出差和EOF情况如果对端发送来的字符是“quit”就回应“COMAAND OK”的字符流乍看上去一切正常。
但仔细看一下,这段代码很有可能会产生下面的结果。
```
char buffer[128];
buffer[128] = '\0';
```
通过recv读取的字符数为128时就会这样的结果。因为buffer的大小只有128字节最后的赋值环节产生了缓冲区溢出的问题。
所谓缓冲区溢出是指计算机程序中出现的一种内存违规操作。本质是计算机程序向缓冲区填充的数据超出了原本缓冲区设置的大小限制导致了数据覆盖了内存栈空间的其他合法数据。这种覆盖破坏了原来程序的完整性使用过游戏修改器的同学肯定知道如果不小心修改错游戏数据的内存空间很可能导致应用程序产生如“Access violation”的错误导致应用程序崩溃。
我们可以对这个程序稍加修改主要的想法是留下buffer里的一个字节以容纳后面的`'\0'`
```
int nBytes = recv(connfd, buffer, sizeof(buffer)-1, 0);
```
这个例子里面,还昭示了一个有趣的现象。你会发现我们发送过去的字符串,调用的是`sizeof`那也就意味着Response字符串中的`'\0'`是被发送出去的,而我们在接收字符时,则假设没有`'\0'`字符的存在。
为了统一我们可以改成如下的方式使用strlen的方式忽略最后一个`'\0'`字符。
```
send(socket, Response, strlen(Response), 0);
```
### 第二个例子
第16讲中提到了对变长报文解析的两种手段一个是使用特殊的边界符号例如HTTP使用的回车换行符另一个是将报文信息的长度编码进入消息。
在实战中,我们也需要对这部分报文长度保持警惕。
```
size_t read_message(int fd, char *buffer, size_t length) {
u_int32_t msg_length;
u_int32_t msg_type;
int rc;
rc = readn(fd, (char *) &amp;msg_length, sizeof(u_int32_t));
if (rc != sizeof(u_int32_t))
return rc &lt; 0 ? -1 : 0;
msg_length = ntohl(msg_length);
rc = readn(fd, (char *) &amp;msg_type, sizeof(msg_type));
if (rc != sizeof(u_int32_t))
return rc &lt; 0 ? -1 : 0;
if (msg_length &gt; length) {
return -1;
}
/* Retrieve the record itself */
rc = readn(fd, buffer, msg_length);
if (rc != msg_length)
return rc &lt; 0 ? -1 : 0;
return rc;
}
```
在进行报文解析时第15行对实际的报文长度`msg_length`和应用程序分配的缓冲区大小进行了比较,如果报文长度过大,导致缓冲区容纳不下,直接返回-1表示出错。千万不要小看这部分的判断试想如果没有这个判断对方程序发送出来的消息体可能构建出一个非常大的`msg_length`,而实际发送的报文本体长度却没有这么大,这样后面的读取操作就不会成功,如果应用程序实际缓冲区大小比`msg_length`小,也产生了缓冲区溢出的问题。
```
struct {
u_int32_t message_length;
u_int32_t message_type;
char data[128];
} message;
int n = 65535;
message.message_length = htonl(n);
message.message_type = 1;
char buf[128] = &quot;just for fun\0&quot;;
strncpy(message.data, buf, strlen(buf));
if (send(socket_fd, (char *) &amp;message,
sizeof(message.message_length) + sizeof(message.message_type) + strlen(message.data), 0) &lt; 0)
error(1, errno, &quot;send failure&quot;);
```
就是这样一段发送端“不小心”构造的一个程序消息的长度“不小心”被设置为65535长度实际发送的报文数据为“just for fun”。在去掉实际的报文长度`msg_length`和应用程序分配的缓冲区大小做比较之后服务器端一直阻塞在read调用上这是因为服务器端误认为需要接收65535大小的字节。
### 第三个例子
如果我们需要开发一个函数,这个函数假设报文的分界符是换行符(\n一个简单的想法是每次读取一个字符判断这个字符是不是换行符。
这里有一个这样的函数这个函数的最大问题是工作效率太低要知道每次调用recv函数都是一次系统调用需要从用户空间切换到内核空间上下文切换的开销对于高性能来说最好是能省则省。
```
size_t readline(int fd, char *buffer, size_t length) {
char *buf_first = buffer;
char c;
while (length &gt; 0 &amp;&amp; recv(fd, &amp;c, 1, 0) == 1) {
*buffer++ = c;
length--;
if (c == '\n') {
*buffer = '\0';
return buffer - buf_first;
}
}
return -1;
}
```
于是就有了第二个版本这个函数一次性读取最多512字节到临时缓冲区之后将临时缓冲区的字符一个一个拷贝到应用程序最终的缓冲区中这样的做法明显效率会高很多。
```
size_t readline(int fd, char *buffer, size_t length) {
char *buf_first = buffer;
static char *buffer_pointer;
int nleft = 0;
static char read_buffer[512];
char c;
while (length-- &gt; 0) {
if (nleft &lt;= 0) {
int nread = recv(fd, read_buffer, sizeof(read_buffer), 0);
if (nread &lt; 0) {
if (errno == EINTR) {
length++;
continue;
}
return -1;
}
if (nread == 0)
return 0;
buffer_pointer = read_buffer;
nleft = nread;
}
c = *buffer_pointer++;
*buffer++ = c;
nleft--;
if (c == '\n') {
*buffer = '\0';
return buffer - buf_first;
}
}
return -1;
}
```
这个程序的主循环在第8行通过对length变量的判断试图解决缓冲区长度溢出问题第9行是判断临时缓冲区的字符有没有被全部拷贝完如果被全部拷贝完就会再次尝试读取最多512字节第20-21行在读取字符成功之后重置了临时缓冲区读指针、临时缓冲区待读的字符个数第23-25行则是在拷贝临时缓冲区字符每次拷贝一个字符并移动临时缓冲区读指针对临时缓冲区待读的字符个数进行减1操作。在程序的26-28行判断是否读到换行符如果读到则将应用程序最终缓冲区截断返回最终读取的字符个数。
这个程序运行起来可能很久都没有问题,但是,它还是有一个微小的瑕疵,这个瑕疵很可能会造成线上故障。
为了讲清这个故障,我们假设这样调用, 输入的字符为`012345678\n`
```
//输入字符为: 012345678\n
char buf[10]
readline(fd, buf, 10)
```
当读到最后一个\n字符时length为1问题是在第26行和27行如果读到了换行符就会增加一个字符串截止符这显然越过了应用程序缓冲区的大小。
这是正确的程序这里最关键的是需要先对length进行处理再去判断length的大小是否可以容纳下字符。
```
size_t readline(int fd, char *buffer, size_t length) {
char *buf_first = buffer;
static char *buffer_pointer;
int nleft = 0;
static char read_buffer[512];
char c;
while (--length&gt; 0) {
if (nleft &lt;= 0) {
int nread = recv(fd, read_buffer, sizeof(read_buffer), 0);
if (nread &lt; 0) {
if (errno == EINTR) {
length++;
continue;
}
return -1;
}
if (nread == 0)
return 0;
buffer_pointer = read_buffer;
nleft = nread;
}
c = *buffer_pointer++;
*buffer++ = c;
nleft--;
if (c == '\n') {
*buffer = '\0';
return buffer - buf_first;
}
}
return -1;
}
```
## 总结
今天的内容到这里就结束了。让我们总结一下: 在网络编程中,是否做好了对各种异常边界的检测,将决定我们的程序在恶劣情况下的稳定性,所以,我们一定要时刻提醒自己做好应对各种复杂情况的准备,这里的异常情况包括缓冲区溢出、指针错误、连接超时检测等。
## 思考题
和往常一样,给你留两道思考题吧。
第一道,我们在读数据的时候,一般都需要给应用程序最终缓冲区分配大小,这个大小有什么讲究吗?
第二道你能分析一下我们文章中的例子所分配的缓冲是否可以换成动态分配吗比如调用malloc函数来分配缓冲区
欢迎你在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。

View File

@@ -0,0 +1,115 @@
<audio id="audio" title="19丨提高篇答疑如何理解TCP四次挥手" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/59/c8/5945e61482c0d49f1bd93e1cae2584c8.mp3"></audio>
你好我是盛延敏这里是网络编程实战第19讲欢迎回来。
这一篇文章是提高篇的答疑部分,也是提高篇的最后一篇文章。非常感谢大家的积极评论与留言,让每一篇文章的留言区都成为学习互动的好地方。在今天的内容里,我将针对大家的问题做一次集中回答,希望能帮助你解决前面碰到的一些问题。
这部分我将采用Q&amp;A的形式来展开。
## 如何理解TCP四次挥手
TCP建立一个连接需3次握手而终止一个连接则需要四次挥手。四次挥手的整个过程是这样的
<img src="https://static001.geekbang.org/resource/image/b8/ea/b8911347d23251b6b0ca07c6ec03a1ea.png" alt=""><br>
首先一方应用程序调用close我们称该方为主动关闭方该端的TCP发送一个FIN包表示需要关闭连接。之后主动关闭方进入FIN_WAIT_1状态。
接着接收到这个FIN包的对端执行被动关闭。这个FIN由TCP协议栈处理我们知道TCP协议栈为FIN包插入一个文件结束符EOF到接收缓冲区中应用程序可以通过read调用来感知这个FIN包。一定要注意这个EOF会被放在**已排队等候的其他已接收的数据之后**这就意味着接收端应用程序需要处理这种异常情况因为EOF表示在该连接上再无额外数据到达。此时被动关闭方进入CLOSE_WAIT状态。
接下来被动关闭方将读到这个EOF于是应用程序也调用close关闭它的套接字这导致它的TCP也发送一个FIN包。这样被动关闭方将进入LAST_ACK状态。
最终主动关闭方接收到对方的FIN包并确认这个FIN包。主动关闭方进入TIME_WAIT状态而接收到ACK的被动关闭方则进入CLOSED状态。经过2MSL时间之后主动关闭方也进入CLOSED状态。
你可以看到每个方向都需要一个FIN和一个ACK因此通常被称为四次挥手。
当然这中间使用shutdown执行一端到另一端的半关闭也是可以的。
当套接字被关闭时TCP为其所在端发送一个FIN包。在大多数情况下这是由应用进程调用close而发生的值得注意的是一个进程无论是正常退出exit或者main函数返回还是非正常退出比如收到SIGKILL信号关闭就是我们常常干的kill -9所有该进程打开的描述符都会被系统关闭这也导致TCP描述符对应的连接上发出一个FIN包。
无论是客户端还是服务器任何一端都可以发起主动关闭。大多数真实情况是客户端执行主动关闭你可能不会想到的是HTTP/1.0却是由服务器发起主动关闭的。
## 最大分组 MSL是TCP 分组在网络中存活的最长时间吗?
MSL是任何IP数据报能够在因特网中存活的最长时间。其实它的实现不是靠计时器来完成的在每个数据报里都包含有一个被称为TTLtime to live的8位字段它的最大值为255。TTL可译为“生存时间”这个生存时间由源主机设置初始值它表示的是一个IP数据报可以经过的最大跳跃数每经过一个路由器就相当于经过了一跳它的值就减1当此值减为0时则所在的路由器会将其丢弃同时发送ICMP报文通知源主机。RFC793中规定MSL的时间为2分钟Linux实际设置为30秒。
## 关于listen函数中参数backlog的释义问题
我们该如何理解listen函数中的参数backlog如果backlog表示的是未完成连接队列的大小那么已完成连接的队列的大小有限制吗如果都是已经建立连接的状态那么并发取决于已完成连接的队列的大小吗
backlog的值含义从来就没有被严格定义过。原先Linux实现中backlog参数定义了该套接字对应的未完成连接队列的最大长度 pending connections)。如果一个连接到达时该队列已满客户端将会接收一个ECONNREFUSED的错误信息如果支持重传该请求可能会被忽略之后会进行一次重传。
从Linux 2.2开始backlog的参数内核有了新的语义它现在定义的是已完成连接队列的最大长度表示的是已建立的连接established connection正在等待被接收accept调用返回而不是原先的未完成队列的最大长度。现在未完成队列的最大长度值可以通过 /proc/sys/net/ipv4/tcp_max_syn_backlog完成修改默认值为128。
至于已完成连接队列如果声明的backlog参数比 /proc/sys/net/core/somaxconn的参数要大那么就会使用我们声明的那个值。实际上这个默认的值为128。注意在Linux 2.4.25之前这个值是不可以修改的一个固定值大小也是128。
设计良好的程序在128固定值的情况下也是可以支持成千上万的并发连接的这取决于I/O分发的效率以及多线程程序的设计。在后面的性能篇里我们的目标就是设计这样的程序。
## UDP连接和断开套接字的过程是怎样的
UDP连接套接字不是发起连接请求的过程而是记录目的地址和端口到套接字的映射关系。
断开套接字则相反,将删除原来记录的映射关系。
## 在UDP中不进行connect为什么客户端会收到信息
有人说如果按照我在文章中的说法UDP只有connect才建立socket和IP地址的映射那么如果不进行connect收到信息后内核又如何把数据交给对应的socket
这个问题非常有意思。我刚刚看到这个问题的时候,心里也在想,是啊,我是不是说错了?
其实呢这对应了两个不同的API场景。
第一个场景就是我这里讨论的connect场景在这个场景里我们讨论的是ICMP报文和socket之间的定位。我们知道ICMP报文发送的是一个不可达的信息不可达的信息是通过**目的地址和端口**来区分的如果没有connect操作**目的地址和端口**就没有办法和socket套接字进行对应所以即使收到了ICMP报文内核也没有办法通知到对应的应用程序告诉它连接地址不可达。
那么为什么在不connect的情况下我们的客户端又可以收到服务器回显的信息了
这就涉及到了第二个场景也就是报文发送的场景。注意服务器端程序先通过recvfrom函数调用获取了客户端的地址和端口信息这当然是可以的因为UDP报文里面包含了这部分信息。然后我们看到服务器端又通过调用sendto函数把客户端的地址和端口信息告诉了内核协议栈可以肯定的是之后发送的UDP报文就带上了**客户端的地址和端口信息**,通过客户端的地址和端口信息,可以找到对应的套接字和应用程序,完成数据的收发。
```
//服务器端程序先通过recvfrom函数调用获取了客户端的地址和端口信息
int n = recvfrom(socket_fd, message, MAXLINE, 0, (struct sockaddr *) &amp;client_addr, &amp;client_len);
message[n] = 0;
printf(&quot;received %d bytes: %s\n&quot;, n, message);
char send_line[MAXLINE];
sprintf(send_line, &quot;Hi, %s&quot;, message);
//服务器端程序调用send函数把客户端的地址和端口信息告诉了内核
sendto(socket_fd, send_line, strlen(send_line), 0, (struct sockaddr *) &amp;client_addr, client_len);
```
从代码中可以看到这里的connect的作用是记录**客户端目的地址和端口–套接字**的关系,而之所以能正确收到从服务器端发送的报文,那是因为系统已经记录了**客户端源地址和端口–套接字**的映射关系。
## 我们是否可以对一个 UDP套接字进行多次connect的操作?
我们知道对于TCP套接字connect只能调用一次。但是对一个UDP套接字来说进行多次connect操作是被允许的这样主要有两个作用。
第一个作用是可以重新指定新的IP地址和端口号第二个作用是可以断开一个已连接的套接字。为了断开一个已连接的UDP套接字第二次调用connect时调用方需要把套接字地址结构的地址族成员设置为AF_UNSPEC。
## 第11讲中程序和时序图的解惑
在11讲中我们讲了关闭连接的几种方式有同学对这一篇文章中的程序和时序图存在疑惑并提出了下面几个问题
1. 代码运行结果是先显示hi data1之后才接收到标准输入的close为什么时序图中画的是先close才接收到hi data1
1. 当一方主动close之后另一方发送数据的时候收到RST。主动方缓冲区会把这个数据丢弃吗这样的话应用层应该读不到了吧
1. 代码中SIGPIPE的作用不是忽略吗为什么服务器端会退出
1. 主动调用socket的那方关闭了写端但是还没关闭读端这时候socket再读到数据是不是就是RST然后再SIGPIPE如果是这样的话为什么不一次性把读写全部关闭呢
我还是再仔细讲一下这个程序和时序图。
首先回答问题1。针对close这个例子时序图里画的close表示的是客户端发起的close调用。
关于问题2“Hi, data1”确实是不应该被接收到的这个数据报即使发送出去也会收到RST回执应用层是读不到的。
关于问题3中SIGPIPE的作用事实上默认的SIGPIPE忽略行为就是退出程序什么也不做当然实际程序还是要做一些清理工作的。
问题4的理解是错误的。第二个例子也显示了如果主动关闭的一方调用shutdown关闭没有关闭读这一端主动关闭的一方可以读到对端的数据注意这个时候主动关闭连接的一方是在使用read方法进行读操作而不是write写操作不会有RST的发生更不会有SIGPIPE的发生。
<img src="https://static001.geekbang.org/resource/image/f2/9a/f283b804c7e33e25a900fedc8c36f09a.png" alt="">
## 总结
以上就是提高篇中一些同学的疑问。我们常说,学问学问,有学才有问。我希望通过今天的答疑可以让你加深对文章的理解,为后面的模块做准备。
这篇文章之后,我们就将进入到专栏中最重要的部分,也就是性能篇和实战篇了,在性能篇和实战篇里,我们将会使用到之前学到的知识,逐渐打造一个高性能的网络程序框架,你,准备好了吗?
如果你觉得今天的答疑内容对你有所帮助,欢迎把它转发给你的朋友或者同事,一起交流一下。

View File

@@ -0,0 +1,342 @@
<audio id="audio" title="32 | 自己动手写高性能HTTP服务器设计和思路" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fe/f1/fe2a0af5589cbe6648d8dcd62c55a6f1.mp3"></audio>
你好我是盛延敏这里是网络编程实战第32讲欢迎回来。
从这一讲开始我们进入实战篇开启一个高性能HTTP服务器的编写之旅。
在开始编写高性能HTTP服务器之前我们先要构建一个支持TCP的高性能网络编程框架完成这个TCP高性能网络框架之后再增加HTTP特性的支持就比较容易了这样就可以很快开发出一个高性能的HTTP服务器程序。
## 设计需求
在第三个模块性能篇中我们已经使用这个网络编程框架完成了多个应用程序的开发这也等于对网络编程框架提出了编程接口方面的需求。综合之前的使用经验TCP高性能网络框架需要满足的需求有以下三点。
第一采用reactor模型可以灵活使用poll/epoll作为事件分发实现。
第二必须支持多线程从而可以支持单线程单reactor模式也可以支持多线程主-从reactor模式。可以将套接字上的I/O事件分离到多个线程上。
第三封装读写操作到Buffer对象中。
按照这三个需求正好可以把整体设计思路分成三块来讲解分别包括反应堆模式设计、I/O模型和多线程模型设计、数据读写封装和buffer。今天我们主要讲一下主要的设计思路和数据结构以及反应堆模式设计。
## 主要设计思路
### 反应堆模式设计
反应堆模式,按照性能篇的讲解,主要是设计一个基于事件分发和回调的反应堆框架。这个框架里面的主要对象包括:
<li>
<h3>event_loop</h3>
</li>
你可以把event_loop这个对象理解成和一个线程绑定的无限事件循环你会在各种语言里看到event_loop这个抽象。这是什么意思呢简单来说它就是一个无限循环着的事件分发器一旦有事件发生它就会回调预先定义好的回调函数完成事件的处理。
具体来说event_loop使用poll或者epoll方法将一个线程阻塞等待各种I/O事件的发生。
<li>
<h3>channel</h3>
</li>
对各种注册到event_loop上的对象我们抽象成channel来表示例如注册到event_loop上的监听事件注册到event_loop上的套接字读写事件等。在各种语言的API里你都会看到channel这个对象大体上它们表达的意思跟我们这里的设计思路是比较一致的。
<li>
<h3>acceptor</h3>
</li>
acceptor对象表示的是服务器端监听器acceptor对象最终会作为一个channel对象注册到event_loop上以便进行连接完成的事件分发和检测。
<li>
<h3>event_dispatcher</h3>
</li>
event_dispatcher是对事件分发机制的一种抽象也就是说可以实现一个基于poll的poll_dispatcher也可以实现一个基于epoll的epoll_dispatcher。在这里我们统一设计一个event_dispatcher结构体来抽象这些行为。
<li>
<h3>channel_map</h3>
</li>
channel_map保存了描述字到channel的映射这样就可以在事件发生时根据事件类型对应的套接字快速找到channel对象里的事件处理函数。
### I/O模型和多线程模型设计
I/O线程和多线程模型主要解决event_loop的线程运行问题以及事件分发和回调的线程执行问题。
<li>
<h3>thread_pool</h3>
</li>
thread_pool维护了一个sub-reactor的线程列表它可以提供给主reactor线程使用每次当有新的连接建立时可以从thread_pool里获取一个线程以便用它来完成对新连接套接字的read/write事件注册将I/O线程和主reactor线程分离。
<li>
<h3>event_loop_thread</h3>
</li>
event_loop_thread是reactor的线程实现连接套接字的read/write事件检测都是在这个线程里完成的。
### Buffer和数据读写
<li>
<h3>buffer</h3>
</li>
buffer对象屏蔽了对套接字进行的写和读的操作如果没有buffer对象连接套接字的read/write事件都需要和字节流直接打交道这显然是不友好的。所以我们也提供了一个基本的buffer对象用来表示从连接套接字收取的数据以及应用程序即将需要发送出去的数据。
<li>
<h3>tcp_connection</h3>
</li>
tcp_connection这个对象描述的是已建立的TCP连接。它的属性包括接收缓冲区、发送缓冲区、channel对象等。这些都是一个TCP连接的天然属性。
tcp_connection是大部分应用程序和我们的高性能框架直接打交道的数据结构。我们不想把最下层的channel对象暴露给应用程序因为抽象的channel对象不仅仅可以表示tcp_connection前面提到的监听套接字也是一个channel对象后面提到的唤醒socketpair也是一个 channel对象。所以我们设计了tcp_connection这个对象希望可以提供给用户比较清晰的编程入口。
## 反应堆模式设计
### 概述
下面我们详细讲解一下以event_loop为核心的反应堆模式设计。这里有一张event_loop的运行详图你可以对照这张图来理解。
<img src="https://static001.geekbang.org/resource/image/7a/61/7ab9f89544aba2021a9d2ceb94ad9661.jpg" alt="">
当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的线程IDmutex和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-&gt;eventDispatcher;
if (eventLoop-&gt;owner_thread_id != pthread_self()) {
exit(1);
}
yolanda_msgx(&quot;event loop run, %s&quot;, eventLoop-&gt;thread_name);
struct timeval timeval;
timeval.tv_sec = 1;
while (!eventLoop-&gt;quit) {
//block here to wait I/O event, and get active channels
dispatcher-&gt;dispatch(eventLoop, &amp;timeval);
//handle the pending channel
event_loop_handle_pending_channel(eventLoop);
}
yolanda_msgx(&quot;event loop end, %s&quot;, eventLoop-&gt;thread_name);
return 0;
}
```
代码很明显地反映了这一点这里我们在event_loop不退出的情况下一直在循环循环体中调用了dispatcher对象的dispatch方法来等待事件的发生。
### event_dispacher分析
为了实现不同的事件分发机制这里把poll、epoll等抽象成了一个event_dispatcher结构。event_dispatcher的具体实现有poll_dispatcher和epoll_dispatcher两种实现的方法和性能篇[21](https://time.geekbang.org/column/article/140520)[](https://time.geekbang.org/column/article/140520)和[22讲](https://time.geekbang.org/column/article/141573)类似,这里就不再赘述,你如果有兴趣的话,可以直接研读代码。
```
/** 抽象的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-&gt;entries[3];
```
这样当event_dispatcher需要回调channel上的读、写函数时调用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-&gt;channelMap;
yolanda_msgx(&quot;activate channel fd == %d, revents=%d, %s&quot;, fd, revents, eventLoop-&gt;thread_name);
if (fd &lt; 0)
return 0;
if (fd &gt;= map-&gt;nentries)return (-1);
struct channel *channel = map-&gt;entries[fd];
assert(fd == channel-&gt;fd);
if (revents &amp; (EVENT_READ)) {
if (channel-&gt;eventReadCallback) channel-&gt;eventReadCallback(channel-&gt;data);
}
if (revents &amp; (EVENT_WRITE)) {
if (channel-&gt;eventWriteCallback) channel-&gt;eventWriteCallback(channel-&gt;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_pending_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(&quot;add channel fd == %d, %s&quot;, fd, eventLoop-&gt;thread_name);
struct channel_map *map = eventLoop-&gt;channelMap;
if (fd &lt; 0)
return 0;
if (fd &gt;= map-&gt;nentries) {
if (map_make_space(map, fd, sizeof(struct channel *)) == -1)
return (-1);
}
//第一次创建,增加
if ((map)-&gt;entries[fd] == NULL) {
map-&gt;entries[fd] = channel;
//add channel
struct event_dispatcher *eventDispatcher = eventLoop-&gt;eventDispatcher;
eventDispatcher-&gt;add(eventLoop, channel);
return 1;
}
return 0;
}
```
## 总结
在这一讲里我们介绍了高性能网络编程框架的主要设计思路和基本数据结构以及反应堆设计相关的具体做法。在接下来的章节中我们将继续编写高性能网络编程框架的线程模型以及读写Buffer部分。
## 思考题
和往常一样,给你留两道思考题:
第一道如果你有兴趣不妨实现一个select_dispatcher对象用select方法实现定义好的event_dispatcher接口
第二道仔细研读channel_map实现中的map_make_space部分说说你的理解。
欢迎你在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。

View File

@@ -0,0 +1,304 @@
<audio id="audio" title="33 | 自己动手写高性能HTTP服务器I/O模型和多线程模型实现" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/16/dc/16941853d20400550cddc171652fcfdc.mp3"></audio>
你好我是盛延敏这里是网络编程实战第33讲欢迎回来。
这一讲我们延续第32讲的话题继续解析高性能网络编程框架的I/O模型和多线程模型设计部分。
## 多线程设计的几个考虑
在我们的设计中main reactor线程是一个acceptor线程这个线程一旦创建会以event_loop形式阻塞在event_dispatcher的dispatch方法上实际上它在等待监听套接字上的事件发生也就是已完成的连接一旦有连接完成就会创建出连接对象tcp_connection以及channel对象等。
当用户期望使用多个sub-reactor子线程时主线程会创建多个子线程每个子线程在创建之后按照主线程指定的启动函数立即运行并进行初始化。随之而来的问题是**主线程如何判断子线程已经完成初始化并启动,继续执行下去呢?这是一个需要解决的重点问题。**
在设置了多个线程的情况下需要将新创建的已连接套接字对应的读写事件交给一个sub-reactor线程处理。所以这里从thread_pool中取出一个线程**通知这个线程有新的事件加入。而这个线程很可能是处于事件分发的阻塞调用之中,如何协调主线程数据写入给子线程,这是另一个需要解决的重点问题。**
子线程是一个event_loop线程它阻塞在dispatch上一旦有事件发生它就会查找channel_map找到对应的处理函数并执行它。之后它就会增加、删除或修改pending事件再次进入下一轮的dispatch。
这张图阐述了线程的运行关系。
<img src="https://static001.geekbang.org/resource/image/55/14/55bb7ef8659395e39395b109dbd28f14.png" alt=""><br>
为了方便你理解,我把对应的函数实现列在了另外一张图中。
<img src="https://static001.geekbang.org/resource/image/da/ca/dac29d3a8fc4f26a09af9e18fc16b2ca.jpg" alt="">
## 主线程等待多个sub-reactor子线程初始化完
主线程需要等待子线程完成初始化,也就是需要获取子线程对应数据的反馈,而子线程初始化也是对这部分数据进行初始化,实际上这是一个多线程的通知问题。采用的做法在[前面](https://time.geekbang.org/column/article/145464)讲多线程的时候也提到过使用mutex和condition两个主要武器。
下面这段代码是主线程发起的子线程创建调用event_loop_thread_init对每个子线程初始化之后调用event_loop_thread_start来启动子线程。注意如果应用程序指定的线程池大小为0则直接返回这样acceptor和I/O事件都会在同一个主线程里处理就退化为单reactor模式。
```
//一定是main thread发起
void thread_pool_start(struct thread_pool *threadPool) {
assert(!threadPool-&gt;started);
assertInSameThread(threadPool-&gt;mainLoop);
threadPool-&gt;started = 1;
void *tmp;
if (threadPool-&gt;thread_number &lt;= 0) {
return;
}
threadPool-&gt;eventLoopThreads = malloc(threadPool-&gt;thread_number * sizeof(struct event_loop_thread));
for (int i = 0; i &lt; threadPool-&gt;thread_number; ++i) {
event_loop_thread_init(&amp;threadPool-&gt;eventLoopThreads[i], i);
event_loop_thread_start(&amp;threadPool-&gt;eventLoopThreads[i]);
}
}
```
我们再看一下event_loop_thread_start这个方法这个方法一定是主线程运行的。这里我使用了pthread_create创建了子线程子线程一旦创建立即执行event_loop_thread_run我们稍后将看到event_loop_thread_run进行了子线程的初始化工作。这个函数最重要的部分是使用了pthread_mutex_lock和pthread_mutex_unlock进行了加锁和解锁并使用了pthread_cond_wait来守候eventLoopThread中的eventLoop的变量。
```
//由主线程调用初始化一个子线程并且让子线程开始运行event_loop
struct event_loop *event_loop_thread_start(struct event_loop_thread *eventLoopThread) {
pthread_create(&amp;eventLoopThread-&gt;thread_tid, NULL, &amp;event_loop_thread_run, eventLoopThread);
assert(pthread_mutex_lock(&amp;eventLoopThread-&gt;mutex) == 0);
while (eventLoopThread-&gt;eventLoop == NULL) {
assert(pthread_cond_wait(&amp;eventLoopThread-&gt;cond, &amp;eventLoopThread-&gt;mutex) == 0);
}
assert(pthread_mutex_unlock(&amp;eventLoopThread-&gt;mutex) == 0);
yolanda_msgx(&quot;event loop thread started, %s&quot;, eventLoopThread-&gt;thread_name);
return eventLoopThread-&gt;eventLoop;
}
```
为什么要这么做呢看一下子线程的代码你就会大致明白。子线程执行函数event_loop_thread_run一上来也是进行了加锁之后初始化event_loop对象当初始化完成之后调用了pthread_cond_signal函数来通知此时阻塞在pthread_cond_wait上的主线程。这样主线程就会从wait中苏醒代码得以往下执行。子线程本身也通过调用event_loop_run进入了一个无限循环的事件分发执行体中等待子线程reator上注册过的事件发生。
```
void *event_loop_thread_run(void *arg) {
struct event_loop_thread *eventLoopThread = (struct event_loop_thread *) arg;
pthread_mutex_lock(&amp;eventLoopThread-&gt;mutex);
// 初始化化event loop之后通知主线程
eventLoopThread-&gt;eventLoop = event_loop_init();
yolanda_msgx(&quot;event loop thread init and signal, %s&quot;, eventLoopThread-&gt;thread_name);
pthread_cond_signal(&amp;eventLoopThread-&gt;cond);
pthread_mutex_unlock(&amp;eventLoopThread-&gt;mutex);
//子线程event loop run
eventLoopThread-&gt;eventLoop-&gt;thread_name = eventLoopThread-&gt;thread_name;
event_loop_run(eventLoopThread-&gt;eventLoop);
}
```
可以看到这里主线程和子线程共享的变量正是每个event_loop_thread的eventLoop对象这个对象在初始化的时候为NULL只有当子线程完成了初始化才变成一个非NULL的值这个变化是子线程完成初始化的标志也是信号量守护的变量。通过使用锁和信号量解决了主线程和子线程同步的问题。当子线程完成初始化之后主线程才会继续往下执行。
```
struct event_loop_thread {
struct event_loop *eventLoop;
pthread_t thread_tid; /* thread ID */
pthread_mutex_t mutex;
pthread_cond_t cond;
char * thread_name;
long thread_count; /* # connections handled */
};
```
你可能会问,主线程是循环在等待每个子线程完成初始化,如果进入第二个循环,等待第二个子线程完成初始化,而此时第二个子线程已经初始化完成了,该怎么办?
注意我们这里一上来是加锁的只要取得了这把锁同时发现event_loop_thread的eventLoop对象已经变成非NULL值可以肯定第二个线程已经初始化就直接释放锁往下执行了。
你可能还会问在执行pthread_cond_wait的时候需要持有那把锁么这里父线程在调用pthread_cond_wait函数之后会立即进入睡眠并释放持有的那把互斥锁。而当父线程再从pthread_cond_wait返回时这是子线程通过pthread_cond_signal通知达成的该线程再次持有那把锁。
## 增加已连接套接字事件到sub-reactor线程中
前面提到主线程是一个main reactor线程这个线程负责检测监听套接字上的事件当有事件发生时也就是一个连接已完成建立如果我们有多个sub-reactor子线程我们期望的结果是把这个已连接套接字相关的I/O事件交给sub-reactor子线程负责检测。这样的好处是main reactor只负责连接套接字的建立可以一直维持在一个非常高的处理效率在多核的情况下多个sub-reactor可以很好地利用上多核处理的优势。
不过,这里有一个令人苦恼的问题。
我们知道sub-reactor线程是一个无限循环的event loop执行体在没有已注册事件发生的情况下这个线程阻塞在event_dispatcher的dispatch上。你可以简单地认为阻塞在poll调用或者epoll_wait上这种情况下主线程如何能把已连接套接字交给sub-reactor子线程呢
当然有办法。
如果我们能让sub-reactor线程从event_dispatcher的dispatch上返回再让sub-reactor线程返回之后能够把新的已连接套接字事件注册上这件事情就算完成了。
那如何让sub-reactor线程从event_dispatcher的dispatch上返回呢答案是构建一个类似管道一样的描述字让event_dispatcher注册该管道描述字当我们想让sub-reactor线程苏醒时往管道上发送一个字符就可以了。
在event_loop_init函数里调用了socketpair函数创建了套接字对这个套接字对的作用就是我刚刚说过的往这个套接字的一端写时另外一端就可以感知到读的事件。其实这里也可以直接使用UNIX上的pipe管道作用是一样的。
```
struct event_loop *event_loop_init() {
...
//add the socketfd to event 这里创建的是套接字对,目的是为了唤醒子线程
eventLoop-&gt;owner_thread_id = pthread_self();
if (socketpair(AF_UNIX, SOCK_STREAM, 0, eventLoop-&gt;socketPair) &lt; 0) {
LOG_ERR(&quot;socketpair set fialed&quot;);
}
eventLoop-&gt;is_handle_pending = 0;
eventLoop-&gt;pending_head = NULL;
eventLoop-&gt;pending_tail = NULL;
eventLoop-&gt;thread_name = &quot;main thread&quot;;
struct channel *channel = channel_new(eventLoop-&gt;socketPair[1], EVENT_READ, handleWakeup, NULL, eventLoop);
event_loop_add_channel_event(eventLoop, eventLoop-&gt;socketPair[1], channel);
return eventLoop;
}
```
要特别注意的是这句代码这告诉event_loop的是注册了socketPair[1]描述字上的READ事件如果有READ事件发生就调用handleWakeup函数来完成事件处理。
```
struct channel *channel = channel_new(eventLoop-&gt;socketPair[1], EVENT_READ, handleWakeup, NULL, eventLoop);
```
我们来看看这个handleWakeup函数
事实上这个函数就是简单的从socketPair[1]描述字上读取了一个字符而已除此之外它什么也没干。它的主要作用就是让子线程从dispatch的阻塞中苏醒。
```
int handleWakeup(void * data) {
struct event_loop *eventLoop = (struct event_loop *) data;
char one;
ssize_t n = read(eventLoop-&gt;socketPair[1], &amp;one, sizeof one);
if (n != sizeof one) {
LOG_ERR(&quot;handleWakeup failed&quot;);
}
yolanda_msgx(&quot;wakeup, %s&quot;, eventLoop-&gt;thread_name);
}
```
现在我们再回过头看看如果有新的连接产生主线程是怎么操作的在handle_connection_established中通过accept调用获取了已连接套接字将其设置为非阻塞套接字切记接下来调用thread_pool_get_loop获取一个event_loop。thread_pool_get_loop的逻辑非常简单从thread_pool线程池中按照顺序挑选出一个线程来服务。接下来是创建了tcp_connection对象。
```
//处理连接已建立的回调函数
int handle_connection_established(void *data) {
struct TCPserver *tcpServer = (struct TCPserver *) data;
struct acceptor *acceptor = tcpServer-&gt;acceptor;
int listenfd = acceptor-&gt;listen_fd;
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
//获取这个已建立的套集字,设置为非阻塞套集字
int connected_fd = accept(listenfd, (struct sockaddr *) &amp;client_addr, &amp;client_len);
make_nonblocking(connected_fd);
yolanda_msgx(&quot;new connection established, socket == %d&quot;, connected_fd);
//从线程池里选择一个eventloop来服务这个新的连接套接字
struct event_loop *eventLoop = thread_pool_get_loop(tcpServer-&gt;threadPool);
// 为这个新建立套接字创建一个tcp_connection对象并把应用程序的callback函数设置给这个tcp_connection对象
struct tcp_connection *tcpConnection = tcp_connection_new(connected_fd, eventLoop,tcpServer-&gt;connectionCompletedCallBack,tcpServer-&gt;connectionClosedCallBack,tcpServer-&gt;messageCallBack,tcpServer-&gt;writeCompletedCallBack);
//callback内部使用
if (tcpServer-&gt;data != NULL) {
tcpConnection-&gt;data = tcpServer-&gt;data;
}
return 0;
}
```
在调用tcp_connection_new创建tcp_connection对象的代码里可以看到先是创建了一个channel对象并注册了READ事件之后调用event_loop_add_channel_event方法往子线程中增加channel对象。
```
tcp_connection_new(int connected_fd, struct event_loop *eventLoop,
connection_completed_call_back connectionCompletedCallBack,
connection_closed_call_back connectionClosedCallBack,
message_call_back messageCallBack, write_completed_call_back writeCompletedCallBack) {
...
//为新的连接对象创建可读事件
struct channel *channel1 = channel_new(connected_fd, EVENT_READ, handle_read, handle_write, tcpConnection);
tcpConnection-&gt;channel = channel1;
//完成对connectionCompleted的函数回调
if (tcpConnection-&gt;connectionCompletedCallBack != NULL) {
tcpConnection-&gt;connectionCompletedCallBack(tcpConnection);
}
//把该套集字对应的channel对象注册到event_loop事件分发器上
event_loop_add_channel_event(tcpConnection-&gt;eventLoop, connected_fd, tcpConnection-&gt;channel);
return tcpConnection;
}
```
请注意到现在为止的操作都是在主线程里执行的。下面的event_loop_do_channel_event也不例外接下来的行为我期望你是熟悉的那就是加解锁。
如果能够获取锁主线程就会调用event_loop_channel_buffer_nolock往子线程的数据中增加需要处理的channel event对象。所有增加的channel对象以列表的形式维护在子线程的数据结构中。
接下来的部分是重点如果当前增加channel event的不是当前event loop线程自己就会调用event_loop_wakeup函数把event_loop子线程唤醒。唤醒的方法很简单就是往刚刚的socketPair[0]上写一个字节别忘了event_loop已经注册了socketPair[1]的可读事件。如果当前增加channel event的是当前event loop线程自己则直接调用event_loop_handle_pending_channel处理新增加的channel event事件列表。
```
int event_loop_do_channel_event(struct event_loop *eventLoop, int fd, struct channel *channel1, int type) {
//get the lock
pthread_mutex_lock(&amp;eventLoop-&gt;mutex);
assert(eventLoop-&gt;is_handle_pending == 0);
//往该线程的channel列表里增加新的channel
event_loop_channel_buffer_nolock(eventLoop, fd, channel1, type);
//release the lock
pthread_mutex_unlock(&amp;eventLoop-&gt;mutex);
//如果是主线程发起操作则调用event_loop_wakeup唤醒子线程
if (!isInSameThread(eventLoop)) {
event_loop_wakeup(eventLoop);
} else {
//如果是子线程自己,则直接可以操作
event_loop_handle_pending_channel(eventLoop);
}
return 0;
}
```
如果是event_loop被唤醒之后接下来也会执行event_loop_handle_pending_channel函数。你可以看到在循环体内从dispatch退出之后也调用了event_loop_handle_pending_channel函数。
```
int event_loop_run(struct event_loop *eventLoop) {
assert(eventLoop != NULL);
struct event_dispatcher *dispatcher = eventLoop-&gt;eventDispatcher;
if (eventLoop-&gt;owner_thread_id != pthread_self()) {
exit(1);
}
yolanda_msgx(&quot;event loop run, %s&quot;, eventLoop-&gt;thread_name);
struct timeval timeval;
timeval.tv_sec = 1;
while (!eventLoop-&gt;quit) {
//block here to wait I/O event, and get active channels
dispatcher-&gt;dispatch(eventLoop, &amp;timeval);
//这里处理pending channel如果是子线程被唤醒这个部分也会立即执行到
event_loop_handle_pending_channel(eventLoop);
}
yolanda_msgx(&quot;event loop end, %s&quot;, eventLoop-&gt;thread_name);
return 0;
}
```
event_loop_handle_pending_channel函数的作用是遍历当前event loop里pending的channel event列表将它们和event_dispatcher关联起来从而修改感兴趣的事件集合。
这里有一个点值得注意因为event loop线程得到活动事件之后会回调事件处理函数这样像onMessage等应用程序代码也会在event loop线程执行如果这里的业务逻辑过于复杂就会导致event_loop_handle_pending_channel执行的时间偏后从而影响I/O的检测。所以将I/O线程和业务逻辑线程隔离让I/O线程只负责处理I/O交互让业务线程处理业务是一个比较常见的做法。
## 总结
在这一讲里我们重点讲解了框架中涉及多线程的两个重要问题第一是主线程如何等待多个子线程完成初始化第二是如何通知处于事件分发中的子线程有新的事件加入、删除、修改。第一个问题通过使用锁和信号量加以解决第二个问题通过使用socketpair并将sockerpair作为channel注册到event loop中来解决。
## 思考题
和往常一样,给你布置两道思考题:
第一道, 你可以修改一下代码让sub-reactor默认的线程个数为cpu*2。
第二道当前选择线程的算法是round-robin的算法你觉得有没有改进的空间如果改进的话你可能会怎么做
欢迎在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,一起交流进步一下。

View File

@@ -0,0 +1,423 @@
<audio id="audio" title="34 | 自己动手写高性能HTTP服务器TCP字节流处理和HTTP协议实现" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f6/fb/f6ee76bc2b5a07fce8b463339e5a27fb.mp3"></audio>
你好我是盛延敏这里是网络编程实战第34讲欢迎回来。
这一讲我们延续第33讲的话题继续解析高性能网络编程框架的字节流处理部分并为网络编程框架增加HTTP相关的功能在此基础上完成HTTP高性能服务器的编写。
## buffer对象
你肯定在各种语言、各种框架里面看到过不同的buffer对象buffer顾名思义就是一个缓冲区对象缓存了从套接字接收来的数据以及需要发往套接字的数据。
如果是从套接字接收来的数据事件处理回调函数在不断地往buffer对象增加数据同时应用程序需要不断把buffer对象中的数据处理掉这样buffer对象才可以空出新的位置容纳更多的数据。
如果是发往套接字的数据应用程序不断地往buffer对象增加数据同时事件处理回调函数不断调用套接字上的发送函数将数据发送出去减少buffer对象中的写入数据。
可见buffer对象是同时可以作为输入缓冲input buffer和输出缓冲output buffer两个方向使用的只不过在两种情形下写入和读出的对象是有区别的。
这张图描述了buffer对象的设计。
<img src="https://static001.geekbang.org/resource/image/44/bb/44eaf37e860212a5c6c9e7f8dc2560bb.png" alt=""><br>
下面是buffer对象的数据结构。
```
//数据缓冲区
struct buffer {
char *data; //实际缓冲
int readIndex; //缓冲读取位置
int writeIndex; //缓冲写入位置
int total_size; //总大小
};
```
buffer对象中的writeIndex标识了当前可以写入的位置readIndex标识了当前可以读出的数据位置图中红色部分从readIndex到writeIndex的区域是需要读出数据的部分而绿色部分从writeIndex到缓存的最尾端则是可以写出的部分。
随着时间的推移当readIndex和writeIndex越来越靠近缓冲的尾端时前面部分的front_space_size区域变得会很大而这个区域的数据已经是旧数据在这个时候就需要调整一下整个buffer对象的结构把红色部分往左侧移动与此同时绿色部分也会往左侧移动整个缓冲区的可写部分就会变多了。
make_room函数就是起这个作用的如果右边绿色的连续空间不足以容纳新的数据而最左边灰色部分加上右边绿色部分一起可以容纳下新数据就会触发这样的移动拷贝最终红色部分占据了最左边绿色部分占据了右边右边绿色的部分成为一个连续的可写入空间就可以容纳下新的数据。下面的一张图解释了这个过程。
<img src="https://static001.geekbang.org/resource/image/63/80/638e76a9f926065a72de9116192ef780.png" alt=""><br>
下面是make_room的具体实现。
```
void make_room(struct buffer *buffer, int size) {
if (buffer_writeable_size(buffer) &gt;= size) {
return;
}
//如果front_spare和writeable的大小加起来可以容纳数据则把可读数据往前面拷贝
if (buffer_front_spare_size(buffer) + buffer_writeable_size(buffer) &gt;= size) {
int readable = buffer_readable_size(buffer);
int i;
for (i = 0; i &lt; readable; i++) {
memcpy(buffer-&gt;data + i, buffer-&gt;data + buffer-&gt;readIndex + i, 1);
}
buffer-&gt;readIndex = 0;
buffer-&gt;writeIndex = readable;
} else {
//扩大缓冲区
void *tmp = realloc(buffer-&gt;data, buffer-&gt;total_size + size);
if (tmp == NULL) {
return;
}
buffer-&gt;data = tmp;
buffer-&gt;total_size += size;
}
}
```
当然如果红色部分占据过大可写部分不够会触发缓冲区的扩大操作。这里我通过调用realloc函数来完成缓冲区的扩容。
下面这张图对此做了解释。
<img src="https://static001.geekbang.org/resource/image/9f/ba/9f66d628572b0ef5b7d9d5989c7a14ba.png" alt="">
## 套接字接收数据处理
套接字接收数据是在tcp_connection.c中的handle_read来完成的。在这个函数里通过调用buffer_socket_read函数接收来自套接字的数据流并将其缓冲到buffer对象中。之后你可以看到我们将buffer对象和tcp_connection对象传递给应用程序真正的处理函数messageCallBack来进行报文的解析工作。这部分的样例在HTTP报文解析中会展开。
```
int handle_read(void *data) {
struct tcp_connection *tcpConnection = (struct tcp_connection *) data;
struct buffer *input_buffer = tcpConnection-&gt;input_buffer;
struct channel *channel = tcpConnection-&gt;channel;
if (buffer_socket_read(input_buffer, channel-&gt;fd) &gt; 0) {
//应用程序真正读取Buffer里的数据
if (tcpConnection-&gt;messageCallBack != NULL) {
tcpConnection-&gt;messageCallBack(input_buffer, tcpConnection);
}
} else {
handle_connection_closed(tcpConnection);
}
}
```
在buffer_socket_read函数里调用readv往两个缓冲区写入数据一个是buffer对象另外一个是这里的additional_buffer之所以这样做是担心buffer对象没办法容纳下来自套接字的数据流而且也没有办法触发buffer对象的扩容操作。通过使用额外的缓冲一旦判断出从套接字读取的数据超过了buffer对象里的实际最大可写大小就可以触发buffer对象的扩容操作这里buffer_append函数会调用前面介绍的make_room函数完成buffer对象的扩容。
```
int buffer_socket_read(struct buffer *buffer, int fd) {
char additional_buffer[INIT_BUFFER_SIZE];
struct iovec vec[2];
int max_writable = buffer_writeable_size(buffer);
vec[0].iov_base = buffer-&gt;data + buffer-&gt;writeIndex;
vec[0].iov_len = max_writable;
vec[1].iov_base = additional_buffer;
vec[1].iov_len = sizeof(additional_buffer);
int result = readv(fd, vec, 2);
if (result &lt; 0) {
return -1;
} else if (result &lt;= max_writable) {
buffer-&gt;writeIndex += result;
} else {
buffer-&gt;writeIndex = buffer-&gt;total_size;
buffer_append(buffer, additional_buffer, result - max_writable);
}
return result;
}
```
## 套接字发送数据处理
当应用程序需要往套接字发送数据时即完成了read-decode-compute-encode过程后通过往buffer对象里写入encode以后的数据调用tcp_connection_send_buffer将buffer里的数据通过套接字缓冲区发送出去。
```
int tcp_connection_send_buffer(struct tcp_connection *tcpConnection, struct buffer *buffer) {
int size = buffer_readable_size(buffer);
int result = tcp_connection_send_data(tcpConnection, buffer-&gt;data + buffer-&gt;readIndex, size);
buffer-&gt;readIndex += size;
return result;
}
```
如果发现当前channel没有注册WRITE事件并且当前tcp_connection对应的发送缓冲无数据需要发送就直接调用write函数将数据发送出去。如果这一次发送不完就将剩余需要发送的数据拷贝到当前tcp_connection对应的发送缓冲区中并向event_loop注册WRITE事件。这样数据就由框架接管应用程序释放这部分数据。
```
//应用层调用入口
int tcp_connection_send_data(struct tcp_connection *tcpConnection, void *data, int size) {
size_t nwrited = 0;
size_t nleft = size;
int fault = 0;
struct channel *channel = tcpConnection-&gt;channel;
struct buffer *output_buffer = tcpConnection-&gt;output_buffer;
//先往套接字尝试发送数据
if (!channel_write_event_registered(channel) &amp;&amp; buffer_readable_size(output_buffer) == 0) {
nwrited = write(channel-&gt;fd, data, size);
if (nwrited &gt;= 0) {
nleft = nleft - nwrited;
} else {
nwrited = 0;
if (errno != EWOULDBLOCK) {
if (errno == EPIPE || errno == ECONNRESET) {
fault = 1;
}
}
}
}
if (!fault &amp;&amp; nleft &gt; 0) {
//拷贝到Buffer中Buffer的数据由框架接管
buffer_append(output_buffer, data + nwrited, nleft);
if (!channel_write_event_registered(channel)) {
channel_write_event_add(channel);
}
}
return nwrited;
}
```
## HTTP协议实现
下面我们在TCP的基础上加入HTTP的功能。
为此我们首先定义了一个http_server结构这个http_server本质上就是一个TCPServer只不过暴露给应用程序的回调函数更为简单只需要看到http_request和http_response结构。
```
typedef int (*request_callback)(struct http_request *httpRequest, struct http_response *httpResponse);
struct http_server {
struct TCPserver *tcpServer;
request_callback requestCallback;
};
```
在http_server里面重点是需要完成报文的解析将解析的报文转化为http_request对象这件事情是通过http_onMessage回调函数来完成的。在http_onMessage函数里调用的是parse_http_request完成报文解析。
```
// buffer是框架构建好的并且已经收到部分数据的情况下
// 注意这里可能没有收到全部数据,所以要处理数据不够的情形
int http_onMessage(struct buffer *input, struct tcp_connection *tcpConnection) {
yolanda_msgx(&quot;get message from tcp connection %s&quot;, tcpConnection-&gt;name);
struct http_request *httpRequest = (struct http_request *) tcpConnection-&gt;request;
struct http_server *httpServer = (struct http_server *) tcpConnection-&gt;data;
if (parse_http_request(input, httpRequest) == 0) {
char *error_response = &quot;HTTP/1.1 400 Bad Request\r\n\r\n&quot;;
tcp_connection_send_data(tcpConnection, error_response, sizeof(error_response));
tcp_connection_shutdown(tcpConnection);
}
//处理完了所有的request数据接下来进行编码和发送
if (http_request_current_state(httpRequest) == REQUEST_DONE) {
struct http_response *httpResponse = http_response_new();
//httpServer暴露的requestCallback回调
if (httpServer-&gt;requestCallback != NULL) {
httpServer-&gt;requestCallback(httpRequest, httpResponse);
}
//将httpResponse发送到套接字发送缓冲区中
struct buffer *buffer = buffer_new();
http_response_encode_buffer(httpResponse, buffer);
tcp_connection_send_buffer(tcpConnection, buffer);
if (http_request_close_connection(httpRequest)) {
tcp_connection_shutdown(tcpConnection);
http_request_reset(httpRequest);
}
}
}
```
还记得[第16讲中](https://time.geekbang.org/column/article/132443)讲到的HTTP协议吗我们从16讲得知HTTP通过设置回车符、换行符作为HTTP报文协议的边界。
<img src="https://static001.geekbang.org/resource/image/6d/5a/6d91c7c2a0224f5d4bad32a0f488765a.png" alt=""><br>
parse_http_request的思路就是寻找报文的边界同时记录下当前解析工作所处的状态。根据解析工作的前后顺序把报文解析的工作分成REQUEST_STATUS、REQUEST_HEADERS、REQUEST_BODY和REQUEST_DONE四个阶段每个阶段解析的方法各有不同。
在解析状态行时先通过定位CRLF回车换行符的位置来圈定状态行进入状态行解析时再次通过查找空格字符来作为分隔边界。
在解析头部设置时也是先通过定位CRLF回车换行符的位置来圈定一组key-value对再通过查找冒号字符来作为分隔边界。
最后,如果没有找到冒号字符,说明解析头部的工作完成。
parse_http_request函数完成了HTTP报文解析的四个阶段:
```
int parse_http_request(struct buffer *input, struct http_request *httpRequest) {
int ok = 1;
while (httpRequest-&gt;current_state != REQUEST_DONE) {
if (httpRequest-&gt;current_state == REQUEST_STATUS) {
char *crlf = buffer_find_CRLF(input);
if (crlf) {
int request_line_size = process_status_line(input-&gt;data + input-&gt;readIndex, crlf, httpRequest);
if (request_line_size) {
input-&gt;readIndex += request_line_size; // request line size
input-&gt;readIndex += 2; //CRLF size
httpRequest-&gt;current_state = REQUEST_HEADERS;
}
}
} else if (httpRequest-&gt;current_state == REQUEST_HEADERS) {
char *crlf = buffer_find_CRLF(input);
if (crlf) {
/**
* &lt;start&gt;-------&lt;colon&gt;:-------&lt;crlf&gt;
*/
char *start = input-&gt;data + input-&gt;readIndex;
int request_line_size = crlf - start;
char *colon = memmem(start, request_line_size, &quot;: &quot;, 2);
if (colon != NULL) {
char *key = malloc(colon - start + 1);
strncpy(key, start, colon - start);
key[colon - start] = '\0';
char *value = malloc(crlf - colon - 2 + 1);
strncpy(value, colon + 1, crlf - colon - 2);
value[crlf - colon - 2] = '\0';
http_request_add_header(httpRequest, key, value);
input-&gt;readIndex += request_line_size; //request line size
input-&gt;readIndex += 2; //CRLF size
} else {
//读到这里说明:没找到,就说明这个是最后一行
input-&gt;readIndex += 2; //CRLF size
httpRequest-&gt;current_state = REQUEST_DONE;
}
}
}
}
return ok;
}
```
处理完了所有的request数据接下来进行编码和发送的工作。为此创建了一个http_response对象并调用了应用程序提供的编码函数requestCallback接下来创建了一个buffer对象函数http_response_encode_buffer用来将http_response中的数据根据HTTP协议转换为对应的字节流。
可以看到http_response_encode_buffer设置了如Content-Length等http_response头部以及http_response的body部分数据。
```
void http_response_encode_buffer(struct http_response *httpResponse, struct buffer *output) {
char buf[32];
snprintf(buf, sizeof buf, &quot;HTTP/1.1 %d &quot;, httpResponse-&gt;statusCode);
buffer_append_string(output, buf);
buffer_append_string(output, httpResponse-&gt;statusMessage);
buffer_append_string(output, &quot;\r\n&quot;);
if (httpResponse-&gt;keep_connected) {
buffer_append_string(output, &quot;Connection: close\r\n&quot;);
} else {
snprintf(buf, sizeof buf, &quot;Content-Length: %zd\r\n&quot;, strlen(httpResponse-&gt;body));
buffer_append_string(output, buf);
buffer_append_string(output, &quot;Connection: Keep-Alive\r\n&quot;);
}
if (httpResponse-&gt;response_headers != NULL &amp;&amp; httpResponse-&gt;response_headers_number &gt; 0) {
for (int i = 0; i &lt; httpResponse-&gt;response_headers_number; i++) {
buffer_append_string(output, httpResponse-&gt;response_headers[i].key);
buffer_append_string(output, &quot;: &quot;);
buffer_append_string(output, httpResponse-&gt;response_headers[i].value);
buffer_append_string(output, &quot;\r\n&quot;);
}
}
buffer_append_string(output, &quot;\r\n&quot;);
buffer_append_string(output, httpResponse-&gt;body);
}
```
## 完整的HTTP服务器例子
现在编写一个HTTP服务器例子就变得非常简单。
在这个例子中最主要的部分是onRequest callback函数这里onRequest方法已经在parse_http_request之后可以根据不同的http_request的信息进行计算和处理。例子程序里的逻辑非常简单根据http request的URL path返回了不同的http_response类型。比如当请求为根目录时返回的是200和HTML格式。
```
#include &lt;lib/acceptor.h&gt;
#include &lt;lib/http_server.h&gt;
#include &quot;lib/common.h&quot;
#include &quot;lib/event_loop.h&quot;
//数据读到buffer之后的callback
int onRequest(struct http_request *httpRequest, struct http_response *httpResponse) {
char *url = httpRequest-&gt;url;
char *question = memmem(url, strlen(url), &quot;?&quot;, 1);
char *path = NULL;
if (question != NULL) {
path = malloc(question - url);
strncpy(path, url, question - url);
} else {
path = malloc(strlen(url));
strncpy(path, url, strlen(url));
}
if (strcmp(path, &quot;/&quot;) == 0) {
httpResponse-&gt;statusCode = OK;
httpResponse-&gt;statusMessage = &quot;OK&quot;;
httpResponse-&gt;contentType = &quot;text/html&quot;;
httpResponse-&gt;body = &quot;&lt;html&gt;&lt;head&gt;&lt;title&gt;This is network programming&lt;/title&gt;&lt;/head&gt;&lt;body&gt;&lt;h1&gt;Hello, network programming&lt;/h1&gt;&lt;/body&gt;&lt;/html&gt;&quot;;
} else if (strcmp(path, &quot;/network&quot;) == 0) {
httpResponse-&gt;statusCode = OK;
httpResponse-&gt;statusMessage = &quot;OK&quot;;
httpResponse-&gt;contentType = &quot;text/plain&quot;;
httpResponse-&gt;body = &quot;hello, network programming&quot;;
} else {
httpResponse-&gt;statusCode = NotFound;
httpResponse-&gt;statusMessage = &quot;Not Found&quot;;
httpResponse-&gt;keep_connected = 1;
}
return 0;
}
int main(int c, char **v) {
//主线程event_loop
struct event_loop *eventLoop = event_loop_init();
//初始tcp_server可以指定线程数目如果线程是0就是在这个线程里acceptor+i/o如果是1有一个I/O线程
//tcp_server自己带一个event_loop
struct http_server *httpServer = http_server_new(eventLoop, SERV_PORT, onRequest, 2);
http_server_start(httpServer);
// main thread for acceptor
event_loop_run(eventLoop);
}
```
运行这个程序之后我们可以通过浏览器和curl命令来访问它。你可以同时开启多个浏览器和curl命令这也证明了我们的程序是可以满足高并发需求的。
```
$curl -v http://127.0.0.1:43211/
* Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to 127.0.0.1 (127.0.0.1) port 43211 (#0)
&gt; GET / HTTP/1.1
&gt; Host: 127.0.0.1:43211
&gt; User-Agent: curl/7.54.0
&gt; Accept: */*
&gt;
&lt; HTTP/1.1 200 OK
&lt; Content-Length: 116
&lt; Connection: Keep-Alive
&lt;
* Connection #0 to host 127.0.0.1 left intact
&lt;html&gt;&lt;head&gt;&lt;title&gt;This is network programming&lt;/title&gt;&lt;/head&gt;&lt;body&gt;&lt;h1&gt;Hello, network programming&lt;/h1&gt;&lt;/body&gt;&lt;/html&gt;%
```
<img src="https://static001.geekbang.org/resource/image/71/a5/719804f279f057a9a12b5904a39e06a5.png" alt="">
## 总结
这一讲我们主要讲述了整个编程框架的字节流处理能力引入了buffer对象并在此基础上通过增加HTTP的特性包括http_server、http_request、http_response完成了HTTP高性能服务器的编写。实例程序利用框架提供的能力编写了一个简单的HTTP服务器程序。
## 思考题
和往常一样,给你布置两道思考题:
第一道, 你可以试着在HTTP服务器中增加MIME的处理能力当用户请求/photo路径时返回一张图片。
第二道,在我们的开发中,已经有很多面向对象的设计,你可以仔细研读代码,说说你对这部分的理解。
欢迎你在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。

View File

@@ -0,0 +1,380 @@
<audio id="audio" title="35 | 答疑:编写高性能网络编程框架时,都需要注意哪些问题?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d1/7c/d13ea87204d334fdb02224935f98017c.mp3"></audio>
你好我是盛延敏这里是网络编程实战的第35讲欢迎回来。
这一篇文章是实战篇的答疑部分,也是本系列的最后一篇文章。非常感谢你的积极评论与留言,让每一篇文章的留言区都成为学习互动的好地方。在今天的内容里,我将针对评论区的问题做一次集中回答,希望能帮助你解决前面碰到的一些问题。
有关这部分内容我将采用Q&amp;A的形式来展开。
## 为什么在发送数据时会先尝试通过socket直接发送再由框架接管呢
这个问题具体描述是下面这样的。
当应用程序需要发送数据时比如下面这段在完成数据读取和回应的编码之后会调用tcp_connection_send_buffer方法发送数据。
```
//数据读到buffer之后的callback
int onMessage(struct buffer *input, struct tcp_connection *tcpConnection) {
printf(&quot;get message from tcp connection %s\n&quot;, tcpConnection-&gt;name);
printf(&quot;%s&quot;, input-&gt;data);
struct buffer *output = buffer_new();
int size = buffer_readable_size(input);
for (int i = 0; i &lt; size; i++) {
buffer_append_char(output, rot13_char(buffer_read_char(input)));
}
tcp_connection_send_buffer(tcpConnection, output);
return 0;
}
```
而tcp_connection_send_buffer方法则会调用tcp_connection_send_data来发送数据
```
int tcp_connection_send_buffer(struct tcp_connection *tcpConnection, struct buffer *buffer) {
int size = buffer_readable_size(buffer);
int result = tcp_connection_send_data(tcpConnection, buffer-&gt;data + buffer-&gt;readIndex, size);
buffer-&gt;readIndex += size;
return result;
}
```
在tcp_connection_send_data中如果发现当前 channel 没有注册 WRITE 事件,并且当前 tcp_connection 对应的发送缓冲无数据需要发送,就直接调用 write 函数将数据发送出去。
```
//应用层调用入口
int tcp_connection_send_data(struct tcp_connection *tcpConnection, void *data, int size) {
size_t nwrited = 0;
size_t nleft = size;
int fault = 0;
struct channel *channel = tcpConnection-&gt;channel;
struct buffer *output_buffer = tcpConnection-&gt;output_buffer;
//先往套接字尝试发送数据
if (!channel_write_event_is_enabled(channel) &amp;&amp; buffer_readable_size(output_buffer) == 0) {
nwrited = write(channel-&gt;fd, data, size);
if (nwrited &gt;= 0) {
nleft = nleft - nwrited;
} else {
nwrited = 0;
if (errno != EWOULDBLOCK) {
if (errno == EPIPE || errno == ECONNRESET) {
fault = 1;
}
}
}
}
if (!fault &amp;&amp; nleft &gt; 0) {
//拷贝到Buffer中Buffer的数据由框架接管
buffer_append(output_buffer, data + nwrited, nleft);
if (!channel_write_event_is_enabled(channel)) {
channel_write_event_enable(channel);
}
}
return nwrited;
}
```
这里有同学不是很理解,为啥不能做成无论有没有 WRITE 事件都统一往发送缓冲区写再把WRITE 事件注册到event_loop中呢
这个问题问得非常好。我觉得有必要展开讲讲。
如果用一句话来总结的话,这是为了发送效率。
我们来分析一下应用层读取数据进行编码之后的这个buffer对象是应用层创建的数据也在应用层这个buffer对象上。你可以理解tcp_connection_send_data里面的data数据其实是应用层缓冲的而不是我们tcp_connection这个对象里面的buffer。
如果我们跳过直接往套接字发送这一段而是把数据交给我们的tcp_connection对应的output_buffer这里有一个数据拷贝的过程它发生在buffer_append里面。
```
int buffer_append(struct buffer *buffer, void *data, int size) {
if (data != NULL) {
make_room(buffer, size);
//拷贝数据到可写空间中
memcpy(buffer-&gt;data + buffer-&gt;writeIndex, data, size);
buffer-&gt;writeIndex += size;
}
}
```
但是,如果增加了一段判断来直接往套接字发送,其实就跳过了这段拷贝,直接把数据发往到了套接字发生缓冲区。
```
//先往套接字尝试发送数据
if (!channel_write_event_is_enabled(channel) &amp;&amp; buffer_readable_size(output_buffer) == 0) {
nwrited = write(channel-&gt;fd, data, size)
...
```
在绝大部分场景下这种处理方式已经满足数据发送的需要了不再需要把数据拷贝到tcp_connection对象中的output_buffer中。
如果不满足直接往套接字发送的条件比如已经注册了回调事件或者output_buffer里面有数据需要发送那么就把数据拷贝到output_buffer中让event_loop的回调不断地驱动handle_write将数据从output_buffer发往套接字缓冲区中。
```
//发送缓冲区可以往外写
//把channel对应的output_buffer不断往外发送
int handle_write(void *data) {
struct tcp_connection *tcpConnection = (struct tcp_connection *) data;
struct event_loop *eventLoop = tcpConnection-&gt;eventLoop;
assertInSameThread(eventLoop);
struct buffer *output_buffer = tcpConnection-&gt;output_buffer;
struct channel *channel = tcpConnection-&gt;channel;
ssize_t nwrited = write(channel-&gt;fd, output_buffer-&gt;data + output_buffer-&gt;readIndex,buffer_readable_size(output_buffer));
if (nwrited &gt; 0) {
//已读nwrited字节
output_buffer-&gt;readIndex += nwrited;
//如果数据完全发送出去,就不需要继续了
if (buffer_readable_size(output_buffer) == 0) {
channel_write_event_disable(channel);
}
//回调writeCompletedCallBack
if (tcpConnection-&gt;writeCompletedCallBack != NULL) {
tcpConnection-&gt;writeCompletedCallBack(tcpConnection);
}
} else {
yolanda_msgx(&quot;handle_write for tcp connection %s&quot;, tcpConnection-&gt;name);
}
}
```
你可以这样想象在一个非常高效的处理条件下你需要发送什么都直接发送给了套接字缓冲区而当网络条件变差处理效率变慢或者待发送的数据极大一次发送不可能完成的时候这部分数据被框架缓冲到tcp_connection的发送缓冲区对象output_buffer中由事件分发机制来负责把这部分数据发送给套接字缓冲区。
## 关于回调函数的设计
在epoll-server-multithreads.c里面定义了很多回调函数比如onMessage onConnectionCompleted等这些回调函数被用于创建一个TCPServer但是在tcp_connection对照中又实现了handle_read handle_write 等事件的回调,似乎有两层回调,为什么要这样封装两层回调呢?
这里如果说回调函数,确实有两个不同层次的回调函数。
第一个层次是框架定义的对连接的生命周期管理的回调。包括连接建立完成后的回调、报文读取并接收到output缓冲区之后的回调、报文发送到套接字缓冲区之后的回调以及连接关闭时的回调。分别是connectionCompletedCallBack、messageCallBack、writeCompletedCallBack以及connectionClosedCallBack。
```
struct tcp_connection {
struct event_loop *eventLoop;
struct channel *channel;
char *name;
struct buffer *input_buffer; //接收缓冲区
struct buffer *output_buffer; //发送缓冲区
connection_completed_call_back connectionCompletedCallBack;
message_call_back messageCallBack;
write_completed_call_back writeCompletedCallBack;
connection_closed_call_back connectionClosedCallBack;
void * data; //for callback use: http_server
void * request; // for callback use
void * response; // for callback use
};
```
为什么要定义这四个回调函数呢?
因为框架需要提供给应用程序和框架的编程接口我把它总结为编程连接点或者叫做program-hook-point。就像是设计了一个抽象类这个抽象类代表了框架给你提供的一个编程入口你可以继承这个抽象类完成一些方法的填充这些方法和框架类一起工作就可以表现出一定符合逻辑的行为。
比如我们定义一个抽象类People这个类的其他属性包括它的创建和管理都可以交给框架来完成但是你需要完成两个函数一个是on_sad这个人悲伤的时候干什么另一个是on_happy这个人高兴的时候干什么。
```
abstract class People{
void on_sad();
void on_happy();
}
```
这样我们可以试着把tcp_connection改成这样
```
abstract class TCP_connection{
void on_connection_completed();
void on_message();
void on_write_completed();
void on_connectin_closed();
}
```
这个层次的回调,更像是一层框架和应用程序约定的接口,接口实现由应用程序来完成,框架负责在合适的时候调用这些预定义好的接口,回调的意思体现在“框架会调用预定好的接口实现”。
比如当连接建立成功一个新的connection创建出来connectionCompletedCallBack函数会被回调
```
struct tcp_connection *
tcp_connection_new(int connected_fd, struct event_loop *eventLoop,
connection_completed_call_back connectionCompletedCallBack,
connection_closed_call_back connectionClosedCallBack,
message_call_back messageCallBack,
write_completed_call_back writeCompletedCallBack) {
...
// add event read for the new connection
struct channel *channel1 = channel_new(connected_fd, EVENT_READ, handle_read, handle_write, tcpConnection);
tcpConnection-&gt;channel = channel1;
//connectionCompletedCallBack callback
if (tcpConnection-&gt;connectionCompletedCallBack != NULL) {
tcpConnection-&gt;connectionCompletedCallBack(tcpConnection);
}
...
}
```
第二个层次的回调是基于epoll、poll事件分发机制的回调。通过注册一定的读、写事件在实际事件发生时由事件分发机制保证对应的事件回调函数被及时调用完成基于事件机制的网络I/O处理。
在每个连接建立之后创建一个对应的channel对象并为这个channel对象赋予了读、写回调函数
```
// add event read for the new connection
struct channel *channel1 = channel_new(connected_fd, EVENT_READ, handle_read, handle_write, tcpConnection);
```
handle_read函数对应用程序屏蔽了套接字的读操作把数据缓冲到tcp_connection的input_buffer中而且它还起到了编程连接点和框架的耦合器的作用这里分别调用了messageCallBack和connectionClosedCallBack函数完成了应用程序编写部分代码在框架的“代入”。
```
int handle_read(void *data) {
struct tcp_connection *tcpConnection = (struct tcp_connection *) data;
struct buffer *input_buffer = tcpConnection-&gt;input_buffer;
struct channel *channel = tcpConnection-&gt;channel;
if (buffer_socket_read(input_buffer, channel-&gt;fd) &gt; 0) {
//应用程序真正读取Buffer里的数据
if (tcpConnection-&gt;messageCallBack != NULL) {
tcpConnection-&gt;messageCallBack(input_buffer, tcpConnection);
}
} else {
handle_connection_closed(tcpConnection);
}
}
```
handle_write函数则负责把tcp_connection对象里的output_buffer源源不断地送往套接字发送缓冲区。
```
//发送缓冲区可以往外写
//把channel对应的output_buffer不断往外发送
int handle_write(void *data) {
struct tcp_connection *tcpConnection = (struct tcp_connection *) data;
struct event_loop *eventLoop = tcpConnection-&gt;eventLoop;
assertInSameThread(eventLoop);
struct buffer *output_buffer = tcpConnection-&gt;output_buffer;
struct channel *channel = tcpConnection-&gt;channel;
ssize_t nwrited = write(channel-&gt;fd, output_buffer-&gt;data + output_buffer-&gt;readIndex,buffer_readable_size(output_buffer));
if (nwrited &gt; 0) {
//已读nwrited字节
output_buffer-&gt;readIndex += nwrited;
//如果数据完全发送出去,就不需要继续了
if (buffer_readable_size(output_buffer) == 0) {
channel_write_event_disable(channel);
}
//回调writeCompletedCallBack
if (tcpConnection-&gt;writeCompletedCallBack != NULL) {
tcpConnection-&gt;writeCompletedCallBack(tcpConnection);
}
} else {
yolanda_msgx(&quot;handle_write for tcp connection %s&quot;, tcpConnection-&gt;name);
}
}
```
## tcp_connection对象设计的想法是什么和channel有什么联系和区别
tcp_connection对象似乎和channel对象有着非常紧密的联系为什么要单独设计一个tcp_connection呢
我也提到了开始的时候我并不打算设计一个tcp_connection对象的后来我才发现非常有必要存在一个tcp_connection对象。
第一我需要在暴露给应用程序的onMessageonConnectionCompleted等回调函数里传递一个有用的数据结构这个数据结构必须有一定的现实语义可以携带一定的信息比如套接字、缓冲区等而channel对象过于单薄和连接的语义相去甚远。
第二这个channel对象是抽象的比如acceptor比如socketpair等它们都是一个channel只要能引起事件的发生和传递都是一个channel基于这一点我也觉得最好把chanel作为一个内部实现的细节不要通过回调函数暴露给应用程序。
第三在后面实现HTTP的过程中我发现需要在上下文中保存http_request和http_response数据而这个部分数据放在channel中是非常不合适的所以才有了最后的tcp_connection对象。
```
struct tcp_connection {
struct event_loop *eventLoop;
struct channel *channel;
char *name;
struct buffer *input_buffer; //接收缓冲区
struct buffer *output_buffer; //发送缓冲区
connection_completed_call_back connectionCompletedCallBack;
message_call_back messageCallBack;
write_completed_call_back writeCompletedCallBack;
connection_closed_call_back connectionClosedCallBack;
void * data; //for callback use: http_server
void * request; // for callback use
void * response; // for callback use
};
```
简单总结下来就是每个tcp_connection对象一定包含了一个channel对象而channel对象未必是一个tcp_connection对象。
## 主线程等待子线程完成的同步锁问题
有人在加锁这里有个疑问如果加锁的目的是让主线程等待子线程初始化event_loop那不加锁不是也可以达到这个目的吗主线程while循环里面不断判断子线程的event_loop是否不为null不就可以了为什么一定要加一把锁呢
```
//由主线程调用初始化一个子线程并且让子线程开始运行event_loop
struct event_loop *event_loop_thread_start(struct event_loop_thread *eventLoopThread) {
pthread_create(&amp;eventLoopThread-&gt;thread_tid, NULL, &amp;event_loop_thread_run, eventLoopThread);
assert(pthread_mutex_lock(&amp;eventLoopThread-&gt;mutex) == 0);
while (eventLoopThread-&gt;eventLoop == NULL) {
assert(pthread_cond_wait(&amp;eventLoopThread-&gt;cond, &amp;eventLoopThread-&gt;mutex) == 0);
}
assert(pthread_mutex_unlock(&amp;eventLoopThread-&gt;mutex) == 0);
yolanda_msgx(&quot;event loop thread started, %s&quot;, eventLoopThread-&gt;thread_name);
return eventLoopThread-&gt;eventLoop;
}
```
要回答这个问题就要解释多线程下共享变量竞争的问题。我们知道一个共享变量在多个线程下同时作用如果没有锁的控制就会引起变量的不同步。这里的共享变量就是每个eventLoopThread的eventLoop对象。
这里如果我们不加锁一直循环判断每个eventLoopThread的状态会对CPU增加很大的消耗如果使用锁-信号量的方式来加以解决就变得很优雅而且不会对CPU造成过多的影响。
## 关于channel_map的设计特别是内存方面的设计。
我们来详细介绍一下channel_map。
channel_map实际上是一个指针数组这个数组里面的每个元素都是一个指针指向了创建出的channel对象。我们用数据下标和套接字进行了映射这样虽然有些元素是浪费了比如stdinstdoutstderr代表的套接字0、1和2但是总体效率是非常高的。
你在这里可以看到图中描绘了channel_map的设计。
<img src="https://static001.geekbang.org/resource/image/a3/fe/a32869877c3bd54f8433267e009002fe.png" alt="">
而且我们的channel_map还不会太占用内存在最开始的时候整个channel_map的指针数组大小为0当这个channel_map投入使用时会根据实际使用的套接字的增长按照32、64、128这样的速度成倍增长这样既保证了实际的需求也不会一下子占用太多的内存。
此外当指针数组增长时我们不会销毁原来的部分而是使用realloc()把旧的内容搬过去再使用memset() 用来给新申请的内存初始化为0值这样既高效也节省内存。
## 总结
以上就是实战篇中一些同学的疑问。
在这篇文章之后,我们的专栏就告一段落了,我希望这个专栏可以帮你梳理清楚高性能网络编程的方方面面,如果你能从中有所领悟,或者帮助你在面试中拿到好的结果,我会深感欣慰。
如果你觉得今天的答疑内容对你有所帮助,欢迎把它转发给你的朋友或者同事,一起交流一下。

View File

@@ -0,0 +1,53 @@
<audio id="audio" title="结束语丨我相信这不是结束,让我们江湖再见" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b5/e9/b5cd8ff3072fa99225d3f9e5216fe6e9.mp3"></audio>
这是“网络编程实战”的最后一篇文章了,提起笔来,竟然感觉到有些不舍,回想起这个专栏的诞生,和总编、编辑反复地讨论选题,根据用户反馈不断打磨大纲,一幕幕都感觉恍如昨日。时间过得飞快,感谢大家几个月以来的相伴,不管大家对这个专栏有什么样的评价,我都可以自豪地说,我是很用心地在做这个事情。感谢极客时间的总编郭蕾,还有这个专栏的编辑,没有你们的支持,很难想象我会坚持完成这个专栏。
虽然我自认是个高性能网络编程领域的老手,但是在此之前,整个知识体系都是碎片化的,实践中虽然有过一些经验和教训,却很难形成一个完整的系列。平时也读过不少网络编程方面的书,大多也是为了解决实践问题进行的查疑解惑,从来没有动力去把这些碎片化的知识加以整理,所以,也非常感谢极客时间的这次邀约,使我能够以一个更加系统的角度把自己之前碰到的问题结合理论加以梳理,最终能形成一个比较完整的系列。我感觉自己在做这个事情的过程中,也收获颇丰。
## 学习方法论
谈完自己的感受,作为这个专栏的结束语,我还是非常希望能给坚持学下来的你们,我的忠实朋友们谈一谈学习方法论。
### 动手实践
前两天和同事们一起去登山,有些同事身体很棒,一溜小跑就奔到了山顶。我在下面远远地看着他们,无比羡慕,萌生出一个想法,如果能不登攀这些石阶直达到山顶,那该多好啊。
可是,这终归是一个不切实际的幻想,我知道我还是要一步一个台阶地迈过这些石阶,才能达到山顶。当我最终到达山顶时,那些疲惫和羡慕都随风而去,自己所能感受到的,则是“无限风光在顶峰”的畅快。
我拿这个登山的例子来类比我们学习计算机技术的过程,这两者是何其的相像。有的同学非常羡慕可以动辄写出框架的专家,其实,要成为专家,中间的一些台阶必须要自己迈过的,没有人可以一步登顶,只是有人跑得快,有人跑得慢而已。
计算机技术是一门非常重视实战的学科,网络编程更需要静下心来不断试错,因为它通过系统内核交互。只有通过试错,才能让你的理解慢慢接近真实的答案。如果只是读文字而不去实践,不可能帮助你成为一个真正的专家。
我在和同学们的互动中,发现有相当多的同学已经能动手去写,并且在这个过程中有不同的领悟,可以问出一些高质量的问题,这都是非常好的现象。**坚持下去,一定会引起质变,让自己成为这个领域的专家。**
### 学习源码
除了动手实践外,看源码也是一个快速提高自己的途径。俗话说,熟读唐诗三百首,不会作诗也会吟。源码读懂了,就可以帮助你大幅度理解原理,技巧也会得到提升。
**在网络编程这块我推荐你看libevent、ACE或者Asio的源代码。**
libevent是一个轻量级的基于event回调机制的网络编程库可以支持Linux、Solaris、Windows等系统它本身是用C语言写的代码量不是很多比较适合入门级的阅读。
Asio是boost里的网络编程库是用C++语言写的。里面用了很多boost的数据结构和技巧包括大量模板的使用有一定的语言难度如果你对自己的C++能力比较自信可以试着去读一读。boost库本身也已经成为C++的标准库,其设计和实现也可以说是一个学习的样板。
ACE是老牌的C++网络编程库我读书那会非常流行也基于ACE写过很多网络程序。很多设计模式如reactor、proactor都是ACE首次提出并倡导的。当然也有很多网络编程的大佬们觉得ACE有点过于设计了搞得太复杂没必要。这个我觉得见仁见智吧有些好的思想可以拿来用觉得不合适该抛弃就抛弃。ACE强在设计模式和抽象如果对设计模式感兴趣可以花一些时间学习下ACE的设计。
### 技术总结和分享
还有一个需要注意的地方就是总结和分享。要学会从源码中总结即使是前人已经有的总结如果你可以有自己的心得和体会也可以总结出来发到个人博客或者GitHub上去。长期坚持下来你也会收获颇丰。我个人在面试候选人的时候对这种比较喜欢总结有自己想法的同学是会高看一眼的。
**GitHub是一个非常好的资源学会使用Git和GitHub会让你如虎添翼。**
总结需要你对原理、代码反复咀嚼。总结的方式有很多种,你可以画框图,写流程图。对技术反复考究的态度,决定了你可以在这条路上走多远。
分享这件事情让我们有一定的成就感,在这其中也不用担心出错,如果有人帮助你纠正了错误的理解,而你付出的不过是倾听和感谢,岂不是一件非常好的事情。
在互联网高度发达的今天,学会技术总结和分享,将会使我们事半功倍。
## 不是再见的再见
我期望这个专栏可以帮助你快速复习和掌握网络编程相关的领域知识、编程模型,为你今后的职业发展奠定良好的基础。你可以写下你的心路历程,非常期望未来和你进一步的互动,我们江湖再见。
文章结尾处有一份调查问卷,希望你可以抽出两三分钟填写一下。我想认真倾听你对这个专栏的意见或者建议,期待你的反馈!
[<img src="https://static001.geekbang.org/resource/image/38/56/38f6fa699492d82e1ecb694378f3ba56.jpg" alt="">](https://jinshuju.net/f/Gg0ER9)