mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2026-05-10 19:54:28 +08:00
mod
This commit is contained in:
62
极客时间专栏/网络编程实战/开篇词/开篇词 | 学好网络编程,需要掌握哪些核心问题?.md
Normal file
62
极客时间专栏/网络编程实战/开篇词/开篇词 | 学好网络编程,需要掌握哪些核心问题?.md
Normal 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="">
|
||||
129
极客时间专栏/网络编程实战/期中复习周/期中大作业丨动手编写一个自己的程序吧!.md
Normal file
129
极客时间专栏/网络编程实战/期中复习周/期中大作业丨动手编写一个自己的程序吧!.md
Normal 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上,并在评论里留下链接。我会认真查看这些代码,并在周五给出自己的反馈意见以及题目分析。由于时间有限,无法尽数查看,后续我会以答疑或者加餐的形式再做补充。
|
||||
|
||||
期待你的成果!
|
||||
|
||||
|
||||
227
极客时间专栏/网络编程实战/期中复习周/期中大作业丨题目以及解答剖析.md
Normal file
227
极客时间专栏/网络编程实战/期中复习周/期中大作业丨题目以及解答剖析.md
Normal 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 "lib/common.h"
|
||||
#define MAXLINE 1024
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (argc != 3) {
|
||||
error(1, 0, "usage: tcp_client <IPaddress> <port>");
|
||||
}
|
||||
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(&allreads);
|
||||
FD_SET(0, &allreads);
|
||||
FD_SET(socket_fd, &allreads);
|
||||
|
||||
for (;;) {
|
||||
readmask = allreads;
|
||||
int rc = select(socket_fd + 1, &readmask, NULL, NULL, NULL);
|
||||
|
||||
if (rc <= 0) {
|
||||
error(1, errno, "select failed");
|
||||
}
|
||||
|
||||
if (FD_ISSET(socket_fd, &readmask)) {
|
||||
n = read(socket_fd, recv_line, MAXLINE);
|
||||
if (n < 0) {
|
||||
error(1, errno, "read error");
|
||||
} else if (n == 0) {
|
||||
printf("server closed \n");
|
||||
break;
|
||||
}
|
||||
recv_line[n] = 0;
|
||||
fputs(recv_line, stdout);
|
||||
fputs("\n", stdout);
|
||||
}
|
||||
|
||||
if (FD_ISSET(STDIN_FILENO, &readmask)) {
|
||||
if (fgets(send_line, MAXLINE, stdin) != NULL) {
|
||||
int i = strlen(send_line);
|
||||
if (send_line[i - 1] == '\n') {
|
||||
send_line[i - 1] = 0;
|
||||
}
|
||||
|
||||
if (strncmp(send_line, "quit", strlen(send_line)) == 0) {
|
||||
if (shutdown(socket_fd, 1)) {
|
||||
error(1, errno, "shutdown failed");
|
||||
}
|
||||
}
|
||||
|
||||
size_t rt = write(socket_fd, send_line, strlen(send_line));
|
||||
if (rt < 0) {
|
||||
error(1, errno, "write failed ");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exit(0);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
客户端的代码主要考虑的是使用select同时处理标准输入和套接字,我看到有同学使用fgets来循环等待用户输入,然后再把输入的命令通过套接字发送出去,当然也是可以正常工作的,只不过不能及时响应来自服务端的命令结果,所以,我还是推荐使用select来同时处理标准输入和套接字。
|
||||
|
||||
这里select如果发现标准输入有事件,读出标准输入的字符,就会通过调用write方法发送出去。如果发现输入的是quit,则调用shutdown方法关闭连接的一端。
|
||||
|
||||
如果select发现套接字流有可读事件,则从套接字中读出数据,并把数据打印到标准输出上;如果读到了EOF,表示该客户端需要退出,直接退出循环,通过调用exit来完成进程的退出。
|
||||
|
||||
## 服务器端程序
|
||||
|
||||
下面是我写的服务器端程序:
|
||||
|
||||
```
|
||||
#include "lib/common.h"
|
||||
static int count;
|
||||
|
||||
static void sig_int(int signo) {
|
||||
printf("\nreceived %d datagrams\n", 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, "r");
|
||||
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(&server_addr, sizeof(server_addr));
|
||||
server_addr.sin_family = AF_INET;
|
||||
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
|
||||
server_addr.sin_port = htons(SERV_PORT);
|
||||
|
||||
int on = 1;
|
||||
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
|
||||
|
||||
int rt1 = bind(listenfd, (struct sockaddr *) &server_addr, sizeof(server_addr));
|
||||
if (rt1 < 0) {
|
||||
error(1, errno, "bind failed ");
|
||||
}
|
||||
|
||||
int rt2 = listen(listenfd, LISTENQ);
|
||||
if (rt2 < 0) {
|
||||
error(1, errno, "listen failed ");
|
||||
}
|
||||
|
||||
signal(SIGPIPE, SIG_IGN);
|
||||
|
||||
int connfd;
|
||||
struct sockaddr_in client_addr;
|
||||
socklen_t client_len = sizeof(client_addr);
|
||||
|
||||
|
||||
char buf[256];
|
||||
count = 0;
|
||||
|
||||
while (1) {
|
||||
if ((connfd = accept(listenfd, (struct sockaddr *) &client_addr, &client_len)) < 0) {
|
||||
error(1, errno, "bind failed ");
|
||||
}
|
||||
|
||||
while (1) {
|
||||
bzero(buf, sizeof(buf));
|
||||
int n = read(connfd, buf, sizeof(buf));
|
||||
if (n < 0) {
|
||||
error(1, errno, "error read message");
|
||||
} else if (n == 0) {
|
||||
printf("client closed \n");
|
||||
close(connfd);
|
||||
break;
|
||||
}
|
||||
count++;
|
||||
buf[n] = 0;
|
||||
if (strncmp(buf, "ls", n) == 0) {
|
||||
char *result = run_cmd("ls");
|
||||
if (send(connfd, result, strlen(result), 0) < 0)
|
||||
return 1;
|
||||
} else if (strncmp(buf, "pwd", n) == 0) {
|
||||
char buf[256];
|
||||
char *result = getcwd(buf, 256);
|
||||
if (send(connfd, result, strlen(result), 0) < 0){
|
||||
return 1;
|
||||
}
|
||||
free(result);
|
||||
} else if (strncmp(buf, "cd ", 3) == 0) {
|
||||
char target[256];
|
||||
bzero(target, sizeof(target));
|
||||
memcpy(target, buf + 3, strlen(buf) - 3);
|
||||
if (chdir(target) == -1) {
|
||||
printf("change dir failed, %s\n", target);
|
||||
}
|
||||
} else {
|
||||
char *error = "error: unknown input type";
|
||||
if (send(connfd, error, strlen(error), 0) < 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服务器。挑战和难度越来越高,你准备好了吗?
|
||||
8
极客时间专栏/网络编程实战/期末测试/期末测试题丨有关网络编程的知识,你掌握了多少呢?.md
Normal file
8
极客时间专栏/网络编程实战/期末测试/期末测试题丨有关网络编程的知识,你掌握了多少呢?.md
Normal 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&exam_id=331)
|
||||
181
极客时间专栏/网络编程实战/第一模块:基础篇/01 | 追古溯源:TCP|IP和Linux是如何改变世界的?.md
Normal file
181
极客时间专栏/网络编程实战/第一模块:基础篇/01 | 追古溯源:TCP|IP和Linux是如何改变世界的?.md
Normal 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 & 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&T贝尔实验室不断开发的UNIX研究版本,从此引出UNIX分时系统第8版、第9版,终止于1990年的第10版(10.5)。这个版本可以说是操作系统界的少林派。天下武功皆出少林,世上UNIX皆出自贝尔实验室。
|
||||
- 图中最上面所标识的操作系统版本,是加州大学伯克利分校(BSD)研究出的分支,从此引出4.xBSD实现,以及后面的各种BSD版本。这个可以看做是学院派。在历史上,学院派有力地推动了UNIX的发展,包括我们后面会谈到的socket套接字都是出自此派。
|
||||
- 图中最下面的那一个部分,是从AT&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
|
||||
|
||||
SVR4(UNIX System V Release 4)是AT&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
|
||||
|
||||
BSD(Berkeley 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标准了,POSIX(Portable 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操作系统得以发展还有一个非常重要的因素,那就是GNU(GNU’s 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 Gnu’s 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,不过没有Linux,GNU也不可能大发光彩。
|
||||
|
||||
在开源的世界里,也会发生这种争名夺利的事情,我们也不用觉得惊奇。
|
||||
|
||||
## 操作系统对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的发展脉络,欢迎把它分享给你的朋友或者同事。
|
||||
151
极客时间专栏/网络编程实战/第一模块:基础篇/02 | 网络编程模型:认识客户端-服务器网络模型的基本概念.md
Normal file
151
极客时间专栏/网络编程实战/第一模块:基础篇/02 | 网络编程模型:认识客户端-服务器网络模型的基本概念.md
Normal 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/24(255.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.0,192.0.2.0就是这个网络的值。
|
||||
|
||||
子网掩码能接受任意个位,而不单纯是上面讨论的8,16或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”的顺序抵达,而且不会出错。
|
||||
|
||||
这种高质量的通信是如何办到的呢?这就是由TCP(Transmission 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段地址,怎么理解这种描述呢?
|
||||
|
||||
另外,章节里提到了服务端必须侦听在一个众所周知的端口上,这个端口怎么选择,又是如何让客户端知道的呢?
|
||||
|
||||
如果你仔细想过这个问题,欢迎在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,大家一起交流一下。
|
||||
219
极客时间专栏/网络编程实战/第一模块:基础篇/03丨套接字和地址:像电话和电话号码一样理解它们.md
Normal file
219
极客时间专栏/网络编程实战/第一模块:基础篇/03丨套接字和地址:像电话和电话号码一样理解它们.md
Normal 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。我们在<sys/socket.h>头文件中可以清晰地看到,这两个值本身就是一一对应的。
|
||||
|
||||
```
|
||||
/* 各种地址族的宏定义 */
|
||||
#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套接字格式却需要端口号呢?
|
||||
|
||||
我在评论区期待你的思考与见解,如果你觉得这篇文章对你有所帮助,欢迎点击“请朋友读”,把这篇文章分享给你朋友或同事。
|
||||
229
极客时间专栏/网络编程实战/第一模块:基础篇/04 | TCP三次握手:怎么使用套接字格式建立连接?.md
Normal file
229
极客时间专栏/网络编程实战/第一模块:基础篇/04 | TCP三次握手:怎么使用套接字格式建立连接?.md
Normal 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 *) &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 <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <sys/socket.h>
|
||||
#include <netinet/in.h>
|
||||
|
||||
|
||||
int make_socket (uint16_t port)
|
||||
{
|
||||
int sock;
|
||||
struct sockaddr_in name;
|
||||
|
||||
|
||||
/* 创建字节流类型的IPV4 socket. */
|
||||
sock = socket (PF_INET, SOCK_STREAM, 0);
|
||||
if (sock < 0)
|
||||
{
|
||||
perror ("socket");
|
||||
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 *) &name, sizeof (name)) < 0)
|
||||
{
|
||||
perror ("bind");
|
||||
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套接字,因为这就是前面通过bind,listen一系列操作而得到的套接字。函数的返回值有两个部分,第一个部分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三次握手的解读
|
||||
|
||||
我们先看一下最初的过程,服务器端通过socket,bind和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连接的建立。
|
||||
|
||||
- 服务器端通过创建socket,bind,listen完成初始化,通过accept完成连接的建立。
|
||||
- 客户端通过创建socket,connect发起连接建立请求。
|
||||
|
||||
在下一讲里,我们将真正地开始客户端-服务端数据交互的过程。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后给你布置两道思考题。
|
||||
|
||||
第一道是关于阻塞调用的,既然有阻塞调用,就应该有非阻塞调用,那么如何使用非阻塞调用套接字呢?使用的场景又是哪里呢?
|
||||
|
||||
第二道是关于客户端的,客户端发起connect调用之前,可以调用bind函数么?
|
||||
|
||||
欢迎你在评论区与我分享你的答案,如果这篇文章帮助你理解TCP三次握手,也欢迎你点击“请朋友读”,把这篇文章分享给你的朋友或者同事。
|
||||
269
极客时间专栏/网络编程实战/第一模块:基础篇/05 | 使用套接字进行读写:开始交流吧.md
Normal file
269
极客时间专栏/网络编程实战/第一模块:基础篇/05 | 使用套接字进行读写:开始交流吧.md
Normal 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,表示EOF(end-of-file),这在网络中表示对端发送了FIN包,要处理断连的情况**;如果返回值为-1,表示出错。当然,如果是非阻塞I/O,情况会略有不同,在后面的提高篇中我们会重点讲述非阻塞I/O的特点。
|
||||
|
||||
注意这里是最多读取size个字节。如果我们想让应用程序每次都读到size个字节,就需要编写下面的函数,不断地循环读取。
|
||||
|
||||
```
|
||||
/* 从socketfd描述字中读取"size"个字节. */
|
||||
size_t readn(int fd, void *buffer, size_t size) {
|
||||
char *buffer_pointer = buffer;
|
||||
int length = size;
|
||||
|
||||
while (length > 0) {
|
||||
int result = read(fd, buffer_pointer, length);
|
||||
|
||||
if (result < 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 "lib/common.h"
|
||||
|
||||
void read_data(int sockfd) {
|
||||
ssize_t n;
|
||||
char buf[1024];
|
||||
|
||||
int time = 0;
|
||||
for (;;) {
|
||||
fprintf(stdout, "block in read\n");
|
||||
if ((n = readn(sockfd, buf, 1024)) == 0)
|
||||
return;
|
||||
|
||||
time++;
|
||||
fprintf(stdout, "1K read for %d \n", 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(&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 *) &servaddr, sizeof(servaddr));
|
||||
/* listen的backlog为1024 */
|
||||
listen(listenfd, 1024);
|
||||
|
||||
/* 循环处理用户请求 */
|
||||
for (;;) {
|
||||
clilen = sizeof(cliaddr);
|
||||
connfd = accept(listenfd, (struct sockaddr *) &cliaddr, &clilen);
|
||||
read_data(connfd); /* 读取数据 */
|
||||
close(connfd); /* 关闭连接套接字,注意不是监听套接字*/
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
对服务器端程序解释如下:
|
||||
|
||||
- 21-35行先后创建了socket套接字,bind到对应地址和端口,并开始调用listen接口监听;
|
||||
- 38-42行循环等待连接,通过accept获取实际的连接,并开始读取数据;
|
||||
- 8-15行实际每次读取1K数据,之后休眠1秒,用来模拟服务器端处理时延。
|
||||
|
||||
### 客户端发送数据程序
|
||||
|
||||
下面是客户端发送数据的程序:
|
||||
|
||||
```
|
||||
#include "lib/common.h"
|
||||
|
||||
#define MESSAGE_SIZE 102400
|
||||
|
||||
void send_data(int sockfd) {
|
||||
char *query;
|
||||
query = malloc(MESSAGE_SIZE + 1);
|
||||
for (int i = 0; i < 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, "send into buffer %ld \n", n_written);
|
||||
if (n_written <= 0) {
|
||||
error(1, errno, "send failed");
|
||||
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, "usage: tcpclient <IPaddress>");
|
||||
|
||||
sockfd = socket(AF_INET, SOCK_STREAM, 0);
|
||||
|
||||
bzero(&servaddr, sizeof(servaddr));
|
||||
servaddr.sin_family = AF_INET;
|
||||
servaddr.sin_port = htons(12345);
|
||||
inet_pton(AF_INET, argv[1], &servaddr.sin_addr);
|
||||
int connect_rt = connect(sockfd, (struct sockaddr *) &servaddr, sizeof(servaddr));
|
||||
if (connect_rt < 0) {
|
||||
error(1, errno, "connect failed ");
|
||||
}
|
||||
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等异常条件。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后你不妨思考一下,既然缓冲区如此重要,我们可不可以把缓冲区搞得大大的,这样不就可以提高应用程序的吞吐量了么?你可以想一想这个方法可行吗?另外你可以自己总结一下,一段数据流从应用程序发送端,一直到应用程序接收端,总共经过了多少次拷贝?
|
||||
|
||||
欢迎你在评论区与我分享你的答案,如果你理解了套接字读写的过程,也欢迎把这篇文章分享给你的朋友或者同事。
|
||||
366
极客时间专栏/网络编程实战/第一模块:基础篇/06 | 嗨,别忘了UDP这个小兄弟.md
Normal file
366
极客时间专栏/网络编程实战/第一模块:基础篇/06 | 嗨,别忘了UDP这个小兄弟.md
Normal 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 <sys/socket.h>
|
||||
|
||||
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 "lib/common.h"
|
||||
|
||||
static int count;
|
||||
|
||||
static void recvfrom_int(int signo) {
|
||||
printf("\nreceived %d datagrams\n", count);
|
||||
exit(0);
|
||||
}
|
||||
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
int socket_fd;
|
||||
socket_fd = socket(AF_INET, SOCK_DGRAM, 0);
|
||||
|
||||
struct sockaddr_in server_addr;
|
||||
bzero(&server_addr, sizeof(server_addr));
|
||||
server_addr.sin_family = AF_INET;
|
||||
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
|
||||
server_addr.sin_port = htons(SERV_PORT);
|
||||
|
||||
bind(socket_fd, (struct sockaddr *) &server_addr, sizeof(server_addr));
|
||||
|
||||
socklen_t client_len;
|
||||
char message[MAXLINE];
|
||||
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 *) &client_addr, &client_len);
|
||||
message[n] = 0;
|
||||
printf("received %d bytes: %s\n", n, message);
|
||||
|
||||
char send_line[MAXLINE];
|
||||
sprintf(send_line, "Hi, %s", message);
|
||||
|
||||
sendto(socket_fd, send_line, strlen(send_line), 0, (struct sockaddr *) &client_addr, client_len);
|
||||
|
||||
count++;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
程序的12~13行,首先创建一个套接字,注意这里的套接字类型是“SOCK_DGRAM”,表示的是UDP数据报。
|
||||
|
||||
15~21行和TCP服务器端类似,绑定数据报套接字到本地的一个端口上。
|
||||
|
||||
27行为该服务器创建了一个信号处理函数,以便在响应“Ctrl+C”退出时,打印出收到的报文总数。
|
||||
|
||||
31~42行是该服务器端的主体,通过调用recvfrom函数获取客户端发送的报文,之后我们对收到的报文进行重新改造,加上“Hi”的前缀,再通过sendto函数发送给客户端对端。
|
||||
|
||||
## UDP客户端例子
|
||||
|
||||
接下来我们再来构建一个对应的UDP客户端。在这个例子中,从标准输入中读取输入的字符串后,发送给服务端,并且把服务端经过处理的报文打印到标准输出上。
|
||||
|
||||
```
|
||||
#include "lib/common.h"
|
||||
|
||||
# define MAXLINE 4096
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (argc != 2) {
|
||||
error(1, 0, "usage: udpclient <IPaddress>");
|
||||
}
|
||||
|
||||
int socket_fd;
|
||||
socket_fd = socket(AF_INET, SOCK_DGRAM, 0);
|
||||
|
||||
struct sockaddr_in server_addr;
|
||||
bzero(&server_addr, sizeof(server_addr));
|
||||
server_addr.sin_family = AF_INET;
|
||||
server_addr.sin_port = htons(SERV_PORT);
|
||||
inet_pton(AF_INET, argv[1], &server_addr.sin_addr);
|
||||
|
||||
socklen_t server_len = sizeof(server_addr);
|
||||
|
||||
struct sockaddr *reply_addr;
|
||||
reply_addr = malloc(server_len);
|
||||
|
||||
char send_line[MAXLINE], recv_line[MAXLINE + 1];
|
||||
socklen_t len;
|
||||
int n;
|
||||
|
||||
while (fgets(send_line, MAXLINE, stdin) != NULL) {
|
||||
int i = strlen(send_line);
|
||||
if (send_line[i - 1] == '\n') {
|
||||
send_line[i - 1] = 0;
|
||||
}
|
||||
|
||||
printf("now sending %s\n", send_line);
|
||||
size_t rt = sendto(socket_fd, send_line, strlen(send_line), 0, (struct sockaddr *) &server_addr, server_len);
|
||||
if (rt < 0) {
|
||||
error(1, errno, "send failed ");
|
||||
}
|
||||
printf("send bytes: %zu \n", rt);
|
||||
|
||||
len = 0;
|
||||
n = recvfrom(socket_fd, recv_line, MAXLINE, 0, reply_addr, &len);
|
||||
if (n < 0)
|
||||
error(1, errno, "recvfrom failed");
|
||||
recv_line[n] = 0;
|
||||
fputs(recv_line, stdout);
|
||||
fputs("\n", stdout);
|
||||
}
|
||||
|
||||
exit(0);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
10~11行创建一个类型为“SOCK_DGRAM”的套接字。
|
||||
|
||||
13~17行,初始化目标服务器的地址和端口。
|
||||
|
||||
28~51行为程序主体,从标准输入中读取的字符进行处理后,调用sendto函数发送给目标服务器端,然后再次调用recvfrom函数接收目标服务器发送过来的新报文,并将其打印到标准输出上。
|
||||
|
||||
为了让你更好地理解UDP和TCP之间的差别,我们模拟一下UDP的三种运行场景,你不妨思考一下这三种场景的结果和TCP的到底有什么不同?
|
||||
|
||||
## 场景一:只运行客户端
|
||||
|
||||
如果我们只运行客户端,程序会一直阻塞在recvfrom上。
|
||||
|
||||
```
|
||||
$ ./udpclient 127.0.0.1
|
||||
1
|
||||
now sending g1
|
||||
send bytes: 2
|
||||
<阻塞在这里>
|
||||
|
||||
```
|
||||
|
||||
还记得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这个协议。
|
||||
424
极客时间专栏/网络编程实战/第一模块:基础篇/07 | What? 还有本地套接字?.md
Normal file
424
极客时间专栏/网络编程实战/第一模块:基础篇/07 | What? 还有本地套接字?.md
Normal 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的CRI(Container 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 "lib/common.h"
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (argc != 2) {
|
||||
error(1, 0, "usage: unixstreamserver <local_path>");
|
||||
}
|
||||
|
||||
int listenfd, connfd;
|
||||
socklen_t clilen;
|
||||
struct sockaddr_un cliaddr, servaddr;
|
||||
|
||||
listenfd = socket(AF_LOCAL, SOCK_STREAM, 0);
|
||||
if (listenfd < 0) {
|
||||
error(1, errno, "socket create failed");
|
||||
}
|
||||
|
||||
char *local_path = argv[1];
|
||||
unlink(local_path);
|
||||
bzero(&servaddr, sizeof(servaddr));
|
||||
servaddr.sun_family = AF_LOCAL;
|
||||
strcpy(servaddr.sun_path, local_path);
|
||||
|
||||
if (bind(listenfd, (struct sockaddr *) &servaddr, sizeof(servaddr)) < 0) {
|
||||
error(1, errno, "bind failed");
|
||||
}
|
||||
|
||||
if (listen(listenfd, LISTENQ) < 0) {
|
||||
error(1, errno, "listen failed");
|
||||
}
|
||||
|
||||
clilen = sizeof(cliaddr);
|
||||
if ((connfd = accept(listenfd, (struct sockaddr *) &cliaddr, &clilen)) < 0) {
|
||||
if (errno == EINTR)
|
||||
error(1, errno, "accept failed"); /* back to for() */
|
||||
else
|
||||
error(1, errno, "accept failed");
|
||||
}
|
||||
|
||||
char buf[BUFFER_SIZE];
|
||||
|
||||
while (1) {
|
||||
bzero(buf, sizeof(buf));
|
||||
if (read(connfd, buf, BUFFER_SIZE) == 0) {
|
||||
printf("client quit");
|
||||
break;
|
||||
}
|
||||
printf("Receive: %s", buf);
|
||||
|
||||
char send_line[MAXLINE];
|
||||
sprintf(send_line, "Hi, %s", buf);
|
||||
|
||||
int nbytes = sizeof(send_line);
|
||||
|
||||
if (write(connfd, send_line, nbytes) != nbytes)
|
||||
error(1, errno, "write error");
|
||||
}
|
||||
|
||||
close(listenfd);
|
||||
close(connfd);
|
||||
|
||||
exit(0);
|
||||
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我对这个程序做一个详细的解释:
|
||||
|
||||
- 第12~15行非常关键,**这里创建的套接字类型,注意是AF_LOCAL,并且使用字节流格式**。你现在可以回忆一下,TCP的类型是AF_INET和字节流类型;UDP的类型是AF_INET和数据报类型。在前面的文章中,我们提到AF_UNIX也是可以的,基本上可以认为和AF_LOCAL是等价的。
|
||||
- 第17~21行创建了一个本地地址,这里的本地地址和IPv4、IPv6地址可以对应,数据类型为sockaddr_un,这个数据类型中的sun_family需要填写为AF_LOCAL,最为关键的是需要对sun_path设置一个本地文件路径。我们这里还做了一个unlink操作,以便把存在的文件删除掉,这样可以保持幂等性。
|
||||
- 第23~29行,分别执行bind和listen操作,这样就监听在一个本地文件路径标识的套接字上,这和普通的TCP服务端程序没什么区别。
|
||||
- 第41~56行,使用read和write函数从套接字中按照字节流的方式读取和发送数据。
|
||||
|
||||
我在这里着重强调一下本地文件路径。关于本地文件路径,需要明确一点,它必须是“绝对路径”,这样的话,编写好的程序可以在任何目录里被启动和管理。如果是“相对路径”,为了保持同样的目的,这个程序的启动路径就必须固定,这样一来,对程序的管理反而是一个很大的负担。
|
||||
|
||||
另外还要明确一点,这个本地文件,必须是一个“文件”,不能是一个“目录”。如果文件不存在,后面bind操作时会自动创建这个文件。
|
||||
|
||||
还有一点需要牢记,在Linux下,任何文件操作都有权限的概念,应用程序启动时也有应用属主。如果当前启动程序的用户权限不能创建文件,你猜猜会发生什么呢?这里我先卖个关子,一会演示的时候你就会看到结果。
|
||||
|
||||
下面我们再看一下客户端程序。
|
||||
|
||||
```
|
||||
#include "lib/common.h"
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (argc != 2) {
|
||||
error(1, 0, "usage: unixstreamclient <local_path>");
|
||||
}
|
||||
|
||||
int sockfd;
|
||||
struct sockaddr_un servaddr;
|
||||
|
||||
sockfd = socket(AF_LOCAL, SOCK_STREAM, 0);
|
||||
if (sockfd < 0) {
|
||||
error(1, errno, "create socket failed");
|
||||
}
|
||||
|
||||
bzero(&servaddr, sizeof(servaddr));
|
||||
servaddr.sun_family = AF_LOCAL;
|
||||
strcpy(servaddr.sun_path, argv[1]);
|
||||
|
||||
if (connect(sockfd, (struct sockaddr *) &servaddr, sizeof(servaddr)) < 0) {
|
||||
error(1, errno, "connect failed");
|
||||
}
|
||||
|
||||
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, "write error");
|
||||
|
||||
if (read(sockfd, recv_line, MAXLINE) == 0)
|
||||
error(1, errno, "server terminated prematurely");
|
||||
|
||||
fputs(recv_line, stdout);
|
||||
}
|
||||
|
||||
exit(0);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
下面我带大家理解一下这个客户端程序。
|
||||
|
||||
- 11~14行创建了一个本地套接字,和前面服务器端程序一样,用的也是字节流类型SOCK_STREAM。
|
||||
- 16~18行初始化目标服务器端的地址。我们知道在TCP编程中,使用的是服务器的IP地址和端口作为目标,在本地套接字中则使用文件路径作为目标标识,sun_path这个字段标识的是目标文件路径,所以这里需要对sun_path进行初始化。
|
||||
- 20行和TCP客户端一样,发起对目标套接字的connect调用,不过由于是本地套接字,并不会有三次握手。
|
||||
- 28~38行从标准输入中读取字符串,向服务器端发送,之后将服务器端传输过来的字符打印到标准输出上。
|
||||
|
||||
总体上,我们可以看到,本地字节流套接字和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 "lib/common.h"
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (argc != 2) {
|
||||
error(1, 0, "usage: unixdataserver <local_path>");
|
||||
}
|
||||
|
||||
int socket_fd;
|
||||
socket_fd = socket(AF_LOCAL, SOCK_DGRAM, 0);
|
||||
if (socket_fd < 0) {
|
||||
error(1, errno, "socket create failed");
|
||||
}
|
||||
|
||||
struct sockaddr_un servaddr;
|
||||
char *local_path = argv[1];
|
||||
unlink(local_path);
|
||||
bzero(&servaddr, sizeof(servaddr));
|
||||
servaddr.sun_family = AF_LOCAL;
|
||||
strcpy(servaddr.sun_path, local_path);
|
||||
|
||||
if (bind(socket_fd, (struct sockaddr *) &servaddr, sizeof(servaddr)) < 0) {
|
||||
error(1, errno, "bind failed");
|
||||
}
|
||||
|
||||
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 *) &client_addr, &client_len) == 0) {
|
||||
printf("client quit");
|
||||
break;
|
||||
}
|
||||
printf("Receive: %s \n", buf);
|
||||
|
||||
char send_line[MAXLINE];
|
||||
bzero(send_line, MAXLINE);
|
||||
sprintf(send_line, "Hi, %s", buf);
|
||||
|
||||
size_t nbytes = strlen(send_line);
|
||||
printf("now sending: %s \n", send_line);
|
||||
|
||||
if (sendto(socket_fd, send_line, nbytes, 0, (struct sockadd *) &client_addr, client_len) != nbytes)
|
||||
error(1, errno, "sendto error");
|
||||
}
|
||||
|
||||
close(socket_fd);
|
||||
|
||||
exit(0);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
本地数据报套接字和前面的字节流本地套接字有以下几点不同:
|
||||
|
||||
- 第9行创建的本地套接字,**这里创建的套接字类型,注意是AF_LOCAL**,协议类型为SOCK_DGRAM。
|
||||
- 21~23行bind到本地地址之后,没有再调用listen和accept,回忆一下,这其实和UDP的性质一样。
|
||||
- 28~45行使用recvfrom和sendto来进行数据报的收发,不再是read和send,这其实也和UDP网络程序一致。
|
||||
|
||||
然后我们再看一下客户端的例子:
|
||||
|
||||
```
|
||||
#include "lib/common.h"
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (argc != 2) {
|
||||
error(1, 0, "usage: unixdataclient <local_path>");
|
||||
}
|
||||
|
||||
int sockfd;
|
||||
struct sockaddr_un client_addr, server_addr;
|
||||
|
||||
sockfd = socket(AF_LOCAL, SOCK_DGRAM, 0);
|
||||
if (sockfd < 0) {
|
||||
error(1, errno, "create socket failed");
|
||||
}
|
||||
|
||||
bzero(&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 *) &client_addr, sizeof(client_addr)) < 0) {
|
||||
error(1, errno, "bind failed");
|
||||
}
|
||||
|
||||
bzero(&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("now sending %s \n", send_line);
|
||||
|
||||
if (sendto(sockfd, send_line, nbytes, 0, (struct sockaddr *) &server_addr, sizeof(server_addr)) != nbytes)
|
||||
error(1, errno, "sendto error");
|
||||
|
||||
int n = recvfrom(sockfd, recv_line, MAXLINE, 0, NULL, NULL);
|
||||
recv_line[n] = 0;
|
||||
|
||||
fputs(recv_line, stdout);
|
||||
fputs("\n", stdout);
|
||||
}
|
||||
|
||||
exit(0);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个程序和UDP网络编程的例子基本是一致的,我们可以把它当作是用本地文件替换了IP地址和端口的UDP程序,不过,这里还是有一个非常大的不同的。
|
||||
|
||||
这个不同点就在16~22行。你可以看到16~22行将本地套接字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,路径和其他都是正确的,你觉得会发生什么呢?
|
||||
|
||||
欢迎你在评论区写下你的思考,我会和你一起交流这些问题。如果这篇文章帮你弄懂了本地套接字,不妨把它分享给你的朋友或者同事,一起交流一下它吧!
|
||||
273
极客时间专栏/网络编程实战/第一模块:基础篇/08 | 工欲善其事必先利其器:学会使用各种工具.md
Normal file
273
极客时间专栏/网络编程实战/第一模块:基础篇/08 | 工欲善其事必先利其器:学会使用各种工具.md
Normal 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序列号排序显示的,一并显示的,也包括TTL(time 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形态的TCP,IPV6形态的TCP、UDP以及UNIX域的套接字都显示出来。
|
||||
|
||||
对于TCP类型来说,最大的好处是可以清楚地看到一条TCP连接的四元组(源地址、源端口、目的地地址和目的端口)。
|
||||
|
||||
例如这里的一条信息:
|
||||
|
||||
```
|
||||
tcp 0 0 127.0.0.1:2379 127.0.0.1:52464 ESTABLISHED 0 27710 3496/etcd
|
||||
|
||||
```
|
||||
|
||||
它表达的意思是本地127.0.0.1的端口52464连上本地127.0.0.1的端口2379,状态为ESTABLISHED,本地进程为etcd,进程为3496。
|
||||
|
||||
这在实战分析的时候非常有用,比如你可以很方便地知道,在某个时候是不是有很多TIME_WAIT的TCP连接,导致端口号被占用光,以致新的连接分配不了。
|
||||
|
||||
当然,我们也可以只对UNIX套接字进行筛查。
|
||||
|
||||
```
|
||||
netstat Socket -x -alepn
|
||||
|
||||
```
|
||||
|
||||
<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]&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 > 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显示的*.*表示的是什么意思呢?
|
||||
|
||||
欢迎你在评论区写下你的思考,我会和你一起交流,也欢迎你把这篇文章分享给你的朋友或者同事,一起讨论下这几个工具。
|
||||
218
极客时间专栏/网络编程实战/第一模块:基础篇/09丨答疑篇:学习网络编程前,需要准备哪些东西?.md
Normal file
218
极客时间专栏/网络编程实战/第一模块:基础篇/09丨答疑篇:学习网络编程前,需要准备哪些东西?.md
Normal 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 && 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 背后的技术叫做LLVM(Low 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 && 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 && 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 中读取报文。
|
||||
|
||||
最后,报文的数据保存在套接字接收缓存中,应用程序从套接字接收缓存中读取数据。
|
||||
|
||||
这就是数据流从应用程序发送端,一直到应用程序接收端的整个过程,你看懂了吗?
|
||||
|
||||
上面的任何一个环节稍有积压,都会对程序性能产生影响。但好消息是,内核和网络设备供应商已经帮我们把一切都打点好了,我们看到和用到的,其实只是冰山上的一角而已。
|
||||
|
||||
这就是基础篇的总结与答疑部分,我先对之前基础篇的内容补充了一些资料,尽可能地为你学习网络编程提供方便,然后针对大家有明显疑惑的问题进行了解答,希望对你有所帮助。
|
||||
227
极客时间专栏/网络编程实战/第三模块:性能篇/20 | 大名⿍⿍的select:看我如何同时感知多个I|O事件.md
Normal file
227
极客时间专栏/网络编程实战/第三模块:性能篇/20 | 大名⿍⿍的select:看我如何同时感知多个I|O事件.md
Normal 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, "usage: select01 <IPaddress>");
|
||||
}
|
||||
int socket_fd = tcp_client(argv[1], SERV_PORT);
|
||||
|
||||
char recv_line[MAXLINE], send_line[MAXLINE];
|
||||
int n;
|
||||
|
||||
fd_set readmask;
|
||||
fd_set allreads;
|
||||
FD_ZERO(&allreads);
|
||||
FD_SET(0, &allreads);
|
||||
FD_SET(socket_fd, &allreads);
|
||||
|
||||
for (;;) {
|
||||
readmask = allreads;
|
||||
int rc = select(socket_fd + 1, &readmask, NULL, NULL, NULL);
|
||||
|
||||
if (rc <= 0) {
|
||||
error(1, errno, "select failed");
|
||||
}
|
||||
|
||||
if (FD_ISSET(socket_fd, &readmask)) {
|
||||
n = read(socket_fd, recv_line, MAXLINE);
|
||||
if (n < 0) {
|
||||
error(1, errno, "read error");
|
||||
} else if (n == 0) {
|
||||
error(1, 0, "server terminated \n");
|
||||
}
|
||||
recv_line[n] = 0;
|
||||
fputs(recv_line, stdout);
|
||||
fputs("\n", stdout);
|
||||
}
|
||||
|
||||
if (FD_ISSET(STDIN_FILENO, &readmask)) {
|
||||
if (fgets(send_line, MAXLINE, stdin) != NULL) {
|
||||
int i = strlen(send_line);
|
||||
if (send_line[i - 1] == '\n') {
|
||||
send_line[i - 1] = 0;
|
||||
}
|
||||
|
||||
printf("now sending %s\n", send_line);
|
||||
size_t rt = write(socket_fd, send_line, strlen(send_line));
|
||||
if (rt < 0) {
|
||||
error(1, errno, "write failed ");
|
||||
}
|
||||
printf("send bytes: %zu \n", rt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
程序的12行通过FD_ZERO初始化了一个描述符集合,这个描述符读集合是空的:
|
||||
|
||||
<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函数里一定需要传入描述字基数这个值么?请你分析一下这样设计的目的又是什么呢?
|
||||
|
||||
欢迎你在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。
|
||||
256
极客时间专栏/网络编程实战/第三模块:性能篇/21 | poll:另一种I|O多路复用.md
Normal file
256
极客时间专栏/网络编程实战/第三模块:性能篇/21 | poll:另一种I|O多路复用.md
Normal 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的行为。
|
||||
|
||||
如果是一个<0的数,表示在有事件发生之前永远等待;如果是0,表示不阻塞进程,立即返回;如果是一个>0的数,表示poll调用方等待指定的毫秒数后返回。
|
||||
|
||||
关于返回值,当有错误发生时,poll函数的返回值为-1;如果在指定的时间到达之前没有任何事件发生,则返回0,否则就返回检测到的事件个数,也就是“returned events”中非0的描述符个数。
|
||||
|
||||
poll函数有一点非常好,如果我们**不想对某个pollfd结构进行事件检测,**可以把它对应的pollfd结构的fd成员设置成一个负值。这样,poll函数将忽略这样的events事件,检测完成以后,所对应的“returned events”的成员值也将设置为0。
|
||||
|
||||
和select函数对比一下,我们发现poll函数和select不一样的地方就是,在select里面,文件描述符的个数已经随着fd_set的实现而固定,没有办法对此进行配置;而在poll函数里,我们可以控制pollfd结构的数组大小,这意味着我们可以突破原来select函数最大描述符的限制,在这种情况下,应用程序调用者需要分配pollfd数组并通知poll函数该数组的大小。
|
||||
|
||||
## 基于poll的服务器程序
|
||||
|
||||
下面我们将开发一个基于poll的服务器程序。这个程序可以同时处理多个客户端连接,并且一旦有客户端数据接收后,同步地回显回去。这已经是一个颇具高并发处理的服务器原型了,再加上后面讲到的非阻塞I/O和多线程等技术,基本上就是可使用的准生产级别了。
|
||||
|
||||
所以,让我们打起精神,一起来看这个程序。
|
||||
|
||||
```
|
||||
#define INIT_SIZE 128
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
int listen_fd, connected_fd;
|
||||
int ready_number;
|
||||
ssize_t n;
|
||||
char buf[MAXLINE];
|
||||
struct sockaddr_in client_addr;
|
||||
|
||||
listen_fd = tcp_server_listen(SERV_PORT);
|
||||
|
||||
//初始化pollfd数组,这个数组的第一个元素是listen_fd,其余的用来记录将要连接的connect_fd
|
||||
struct pollfd event_set[INIT_SIZE];
|
||||
event_set[0].fd = listen_fd;
|
||||
event_set[0].events = POLLRDNORM;
|
||||
|
||||
// 用-1表示这个数组位置还没有被占用
|
||||
int i;
|
||||
for (i = 1; i < INIT_SIZE; i++) {
|
||||
event_set[i].fd = -1;
|
||||
}
|
||||
|
||||
for (;;) {
|
||||
if ((ready_number = poll(event_set, INIT_SIZE, -1)) < 0) {
|
||||
error(1, errno, "poll failed ");
|
||||
}
|
||||
|
||||
if (event_set[0].revents & POLLRDNORM) {
|
||||
socklen_t client_len = sizeof(client_addr);
|
||||
connected_fd = accept(listen_fd, (struct sockaddr *) &client_addr, &client_len);
|
||||
|
||||
//找到一个可以记录该连接套接字的位置
|
||||
for (i = 1; i < INIT_SIZE; i++) {
|
||||
if (event_set[i].fd < 0) {
|
||||
event_set[i].fd = connected_fd;
|
||||
event_set[i].events = POLLRDNORM;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (i == INIT_SIZE) {
|
||||
error(1, errno, "can not hold so many clients");
|
||||
}
|
||||
|
||||
if (--ready_number <= 0)
|
||||
continue;
|
||||
}
|
||||
|
||||
for (i = 1; i < INIT_SIZE; i++) {
|
||||
int socket_fd;
|
||||
if ((socket_fd = event_set[i].fd) < 0)
|
||||
continue;
|
||||
if (event_set[i].revents & (POLLRDNORM | POLLERR)) {
|
||||
if ((n = read(socket_fd, buf, MAXLINE)) > 0) {
|
||||
if (write(socket_fd, buf, n) < 0) {
|
||||
error(1, errno, "write error");
|
||||
}
|
||||
} else if (n == 0 || errno == ECONNRESET) {
|
||||
close(socket_fd);
|
||||
event_set[i].fd = -1;
|
||||
} else {
|
||||
error(1, errno, "read error");
|
||||
}
|
||||
|
||||
if (--ready_number <= 0)
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
当然,一开始需要创建一个监听套接字,并绑定在本地的地址和端口上,这在第10行调用tcp_server_listen函数来完成。
|
||||
|
||||
在第13行,我初始化了一个pollfd数组,并命名为event_set,之所以叫这个名字,是引用pollfd数组确实代表了检测的事件集合。这里数组的大小固定为INIT_SIZE,这在实际的生产环境肯定是需要改进的。
|
||||
|
||||
我在前面讲过,监听套接字上如果有连接建立完成,也是可以通过 I/O事件复用来检测到的。在第14-15行,将监听套接字listen_fd和对应的POLLRDNORM事件加入到event_set里,表示我们期望系统内核检测监听套接字上的连接建立完成事件。
|
||||
|
||||
在前面介绍poll函数时,我们提到过,如果对应pollfd里的文件描述字fd为负数,poll函数将会忽略这个pollfd,所以我们在第18-21行将event_set数组里其他没有用到的fd统统设置为-1。这里-1也表示了当前pollfd没有被使用的意思。
|
||||
|
||||
下面我们的程序进入一个无限循环,在这个循环体内,第24行调用poll函数来进行事件检测。poll函数传入的参数为event_set数组,数组大小INIT_SIZE和-1。这里之所以传入INIT_SIZE,是因为poll函数已经能保证可以自动忽略fd为-1的pollfd,否则我们每次都需要计算一下event_size里真正需要被检测的元素大小;timeout设置为-1,表示在I/O事件发生之前poll调用一直阻塞。
|
||||
|
||||
如果系统内核检测到监听套接字上的连接建立事件,就进入到第28行的判断分支。我们看到,使用了如event_set[0].revent来和对应的事件类型进行位与操作,这个技巧大家一定要记住,这是因为event都是通过二进制位来进行记录的,位与操作是和对应的二进制位进行操作,一个文件描述字是可以对应到多个事件类型的。
|
||||
|
||||
在这个分支里,调用accept函数获取了连接描述字。接下来,33-38行做了一件事,就是把连接描述字connect_fd也加入到event_set里,而且说明了我们感兴趣的事件类型为POLLRDNORM,也就是套接字上有数据可以读。在这里,我们从数组里查找一个没有没占用的位置,也就是fd为-1的位置,然后把fd设置为新的连接套接字connect_fd。
|
||||
|
||||
如果在数组里找不到这样一个位置,说明我们的event_set已经被很多连接充满了,没有办法接收更多的连接了,这就是第41-42行所做的事情。
|
||||
|
||||
第45-46行是一个加速优化能力,因为poll返回的一个整数,说明了这次I/O事件描述符的个数,如果处理完监听套接字之后,就已经完成了这次I/O复用所要处理的事情,那么我们就可以跳过后面的处理,再次进入poll调用。
|
||||
|
||||
接下来的循环处理是查看event_set里面其他的事件,也就是已连接套接字的可读事件。这是通过遍历event_set数组来完成的。
|
||||
|
||||
如果数组里的pollfd的fd为-1,说明这个pollfd没有递交有效的检测,直接跳过;来到第53行,通过检测revents的事件类型是POLLRDNORM或者POLLERR,我们可以进行读操作。在第54行,读取数据正常之后,再通过write操作回显给客户端;在第58行,如果读到EOF或者是连接重置,则关闭这个连接,并且把event_set对应的pollfd重置;第61行读取数据失败。
|
||||
|
||||
和前面的优化加速处理一样,第65-66行是判断如果事件已经被完全处理完之后,直接跳过对event_set的循环处理,再次来到poll调用。
|
||||
|
||||
## 实验
|
||||
|
||||
我们启动这个服务器程序,然后通过telnet连接到这个服务器程序。为了检验这个服务器程序的I/O复用能力,我们可以多开几个telnet客户端,并且在屏幕上输入各种字符串。
|
||||
|
||||
客户端1:
|
||||
|
||||
```
|
||||
$telnet 127.0.0.1 43211
|
||||
Trying 127.0.0.1...
|
||||
Connected to 127.0.0.1.
|
||||
Escape character is '^]'.
|
||||
a
|
||||
a
|
||||
aaaaaaaaaaa
|
||||
aaaaaaaaaaa
|
||||
afafasfa
|
||||
afafasfa
|
||||
fbaa
|
||||
fbaa
|
||||
^]
|
||||
|
||||
|
||||
telnet> quit
|
||||
Connection closed.
|
||||
|
||||
```
|
||||
|
||||
客户端2:
|
||||
|
||||
```
|
||||
telnet 127.0.0.1 43211
|
||||
Trying 127.0.0.1...
|
||||
Connected to 127.0.0.1.
|
||||
Escape character is '^]'.
|
||||
b
|
||||
b
|
||||
bbbbbbb
|
||||
bbbbbbb
|
||||
bbbbbbb
|
||||
bbbbbbb
|
||||
^]
|
||||
|
||||
|
||||
telnet> quit
|
||||
Connection closed.
|
||||
|
||||
```
|
||||
|
||||
可以看到,这两个客户端互不影响,每个客户端输入的字符很快会被回显到客户端屏幕上。一个客户端断开连接,也不会影响到其他客户端。
|
||||
|
||||
## 总结
|
||||
|
||||
poll是另一种在各种UNIX系统上被广泛支持的I/O多路复用技术,虽然名声没有select那么响,能力一点不比select差,而且因为可以突破select文件描述符的个数限制,在高并发的场景下尤其占优势。这一讲我们编写了一个基于poll的服务器程序,希望你从中学会poll的用法。
|
||||
|
||||
## 思考题
|
||||
|
||||
和往常一样,给你留两道思考题:
|
||||
|
||||
第一道,在我们的程序里event_set数组的大小固定为INIT_SIZE,这在实际的生产环境肯定是需要改进的。你知道如何改进吗?
|
||||
|
||||
第二道,如果我们进行了改进,那么接下来把连接描述字connect_fd也加入到event_set里,如何配合进行改造呢?
|
||||
|
||||
欢迎你在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。
|
||||
348
极客时间专栏/网络编程实战/第三模块:性能篇/22 | 非阻塞I|O:提升性能的加速器.md
Normal file
348
极客时间专栏/网络编程实战/第三模块:性能篇/22 | 非阻塞I|O:提升性能的加速器.md
Normal 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 > 0) {
|
||||
if ( (nwritten = write(fd, ptr, nleft)) <= 0) {
|
||||
/* 这里EAGAIN是非阻塞non-blocking情况下,通知我们再次调用write() */
|
||||
if (nwritten < 0 && 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, &ling, sizeof(ling));
|
||||
close(socket_fd);
|
||||
|
||||
```
|
||||
|
||||
服务器端使用select I/O多路复用,不过,监听套接字仍然是blocking的。如果监听套接字上有事件发生,休眠5秒,以便模拟高并发场景下的情形。
|
||||
|
||||
```
|
||||
if (FD_ISSET(listen_fd, &readset)) {
|
||||
printf("listening socket readable\n");
|
||||
sleep(5);
|
||||
struct sockaddr_storage ss;
|
||||
socklen_t slen = sizeof(ss);
|
||||
int fd = accept(listen_fd, (struct sockaddr *) &ss, &slen);
|
||||
|
||||
```
|
||||
|
||||
这里的休眠时间非常关键,这样,在监听套接字上有可读事件发生时,并没有马上调用accept。由于客户端发生了RST分节,该连接被接收端内核从自己的已完成队列中删除了,此时再调用accept,由于没有已完成连接(假设没有其他已完成连接),accept一直阻塞,更为严重的是,该线程再也没有机会对其他I/O事件进行分发,相当于该服务器无法对其他I/O进行服务。
|
||||
|
||||
如果我们将监听套接字设为非阻塞,上述的情形就不会再发生。只不过对于accept的返回值,需要正确地处理各种看似异常的错误,例如忽略EWOULDBLOCK、EAGAIN等。
|
||||
|
||||
这个例子给我们的启发是,一定要将监听套接字设置为非阻塞的,尽管这里休眠时间5秒有点夸张,但是在极端情况下处理不当的服务器程序是有可能碰到例子所阐述的情况,为了让服务器程序在极端情况下工作正常,这点工作还是非常值得的。
|
||||
|
||||
### connect
|
||||
|
||||
在非阻塞TCP套接字上调用connect函数,会立即返回一个EINPROGRESS错误。TCP三次握手会正常进行,应用程序可以继续做其他初始化的事情。当该连接建立成功或者失败时,通过I/O多路复用select、poll等可以进行连接的状态检测。
|
||||
|
||||
## 非阻塞I/O + select多路复用
|
||||
|
||||
我在这里给出了一个非阻塞I/O搭配select多路复用的例子。
|
||||
|
||||
```
|
||||
#define MAX_LINE 1024
|
||||
#define FD_INIT_SIZE 128
|
||||
|
||||
char rot13_char(char c) {
|
||||
if ((c >= 'a' && c <= 'm') || (c >= 'A' && c <= 'M'))
|
||||
return c + 13;
|
||||
else if ((c >= 'n' && c <= 'z') || (c >= 'N' && c <= 'Z'))
|
||||
return c - 13;
|
||||
else
|
||||
return c;
|
||||
}
|
||||
|
||||
//数据缓冲区
|
||||
struct Buffer {
|
||||
int connect_fd; //连接字
|
||||
char buffer[MAX_LINE]; //实际缓冲
|
||||
size_t writeIndex; //缓冲写入位置
|
||||
size_t readIndex; //缓冲读取位置
|
||||
int readable; //是否可以读
|
||||
};
|
||||
|
||||
struct Buffer *alloc_Buffer() {
|
||||
struct Buffer *buffer = malloc(sizeof(struct Buffer));
|
||||
if (!buffer)
|
||||
return NULL;
|
||||
buffer->connect_fd = 0;
|
||||
buffer->writeIndex = buffer->readIndex = buffer->readable = 0;
|
||||
return buffer;
|
||||
}
|
||||
|
||||
void free_Buffer(struct Buffer *buffer) {
|
||||
free(buffer);
|
||||
}
|
||||
|
||||
int onSocketRead(int fd, struct Buffer *buffer) {
|
||||
char buf[1024];
|
||||
int i;
|
||||
ssize_t result;
|
||||
while (1) {
|
||||
result = recv(fd, buf, sizeof(buf), 0);
|
||||
if (result <= 0)
|
||||
break;
|
||||
|
||||
for (i = 0; i < result; ++i) {
|
||||
if (buffer->writeIndex < sizeof(buffer->buffer))
|
||||
buffer->buffer[buffer->writeIndex++] = rot13_char(buf[i]);
|
||||
if (buf[i] == '\n') {
|
||||
buffer->readable = 1; //缓冲区可以读
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result == 0) {
|
||||
return 1;
|
||||
} else if (result < 0) {
|
||||
if (errno == EAGAIN)
|
||||
return 0;
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
int onSocketWrite(int fd, struct Buffer *buffer) {
|
||||
while (buffer->readIndex < buffer->writeIndex) {
|
||||
ssize_t result = send(fd, buffer->buffer + buffer->readIndex, buffer->writeIndex - buffer->readIndex, 0);
|
||||
if (result < 0) {
|
||||
if (errno == EAGAIN)
|
||||
return 0;
|
||||
return -1;
|
||||
}
|
||||
|
||||
buffer->readIndex += result;
|
||||
}
|
||||
|
||||
if (buffer->readIndex == buffer->writeIndex)
|
||||
buffer->readIndex = buffer->writeIndex = 0;
|
||||
|
||||
buffer->readable = 0;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
int listen_fd;
|
||||
int i, maxfd;
|
||||
|
||||
struct Buffer *buffer[FD_INIT_SIZE];
|
||||
for (i = 0; i < FD_INIT_SIZE; ++i) {
|
||||
buffer[i] = alloc_Buffer();
|
||||
}
|
||||
|
||||
listen_fd = tcp_nonblocking_server_listen(SERV_PORT);
|
||||
|
||||
fd_set readset, writeset, exset;
|
||||
FD_ZERO(&readset);
|
||||
FD_ZERO(&writeset);
|
||||
FD_ZERO(&exset);
|
||||
|
||||
while (1) {
|
||||
maxfd = listen_fd;
|
||||
|
||||
FD_ZERO(&readset);
|
||||
FD_ZERO(&writeset);
|
||||
FD_ZERO(&exset);
|
||||
|
||||
// listener加入readset
|
||||
FD_SET(listen_fd, &readset);
|
||||
|
||||
for (i = 0; i < FD_INIT_SIZE; ++i) {
|
||||
if (buffer[i]->connect_fd > 0) {
|
||||
if (buffer[i]->connect_fd > maxfd)
|
||||
maxfd = buffer[i]->connect_fd;
|
||||
FD_SET(buffer[i]->connect_fd, &readset);
|
||||
if (buffer[i]->readable) {
|
||||
FD_SET(buffer[i]->connect_fd, &writeset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (select(maxfd + 1, &readset, &writeset, &exset, NULL) < 0) {
|
||||
error(1, errno, "select error");
|
||||
}
|
||||
|
||||
if (FD_ISSET(listen_fd, &readset)) {
|
||||
printf("listening socket readable\n");
|
||||
sleep(5);
|
||||
struct sockaddr_storage ss;
|
||||
socklen_t slen = sizeof(ss);
|
||||
int fd = accept(listen_fd, (struct sockaddr *) &ss, &slen);
|
||||
if (fd < 0) {
|
||||
error(1, errno, "accept failed");
|
||||
} else if (fd > FD_INIT_SIZE) {
|
||||
error(1, 0, "too many connections");
|
||||
close(fd);
|
||||
} else {
|
||||
make_nonblocking(fd);
|
||||
if (buffer[fd]->connect_fd == 0) {
|
||||
buffer[fd]->connect_fd = fd;
|
||||
} else {
|
||||
error(1, 0, "too many connections");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (i = 0; i < maxfd + 1; ++i) {
|
||||
int r = 0;
|
||||
if (i == listen_fd)
|
||||
continue;
|
||||
|
||||
if (FD_ISSET(i, &readset)) {
|
||||
r = onSocketRead(i, buffer[i]);
|
||||
}
|
||||
if (r == 0 && FD_ISSET(i, &writeset)) {
|
||||
r = onSocketWrite(i, buffer[i]);
|
||||
}
|
||||
if (r) {
|
||||
buffer[i]->connect_fd = 0;
|
||||
close(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
第93行,调用fcntl将监听套接字设置为非阻塞。
|
||||
|
||||
```
|
||||
fcntl(fd, F_SETFL, O_NONBLOCK);
|
||||
|
||||
```
|
||||
|
||||
第121行调用select进行I/O事件分发处理。
|
||||
|
||||
131-142行在处理新的连接套接字,注意这里也把连接套接字设置为非阻塞的。
|
||||
|
||||
151-156行在处理连接套接字上的I/O读写事件,这里我们抽象了一个Buffer对象,Buffer对象使用了readIndex和writeIndex分别表示当前缓冲的读写位置。
|
||||
|
||||
## 实验
|
||||
|
||||
启动该服务器:
|
||||
|
||||
```
|
||||
$./nonblockingserver
|
||||
|
||||
```
|
||||
|
||||
使用多个telnet客户端连接该服务器,可以验证交互正常。
|
||||
|
||||
```
|
||||
$telnet 127.0.0.1 43211
|
||||
Trying 127.0.0.1...
|
||||
Connected to localhost.
|
||||
Escape character is '^]'.
|
||||
fasfasfasf
|
||||
snfsnfsnfs
|
||||
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
非阻塞I/O可以使用在read、write、accept、connect等多种不同的场景,在非阻塞I/O下,使用轮询的方式引起CPU占用率高,所以一般将非阻塞I/O和I/O多路复用技术select、poll等搭配使用,在非阻塞I/O事件发生时,再调用对应事件的处理函数。这种方式,极大地提高了程序的健壮性和稳定性,是Linux下高性能网络编程的首选。
|
||||
|
||||
## 思考题
|
||||
|
||||
给你布置两道思考题:
|
||||
|
||||
第一道,程序中第133行这个判断说明了什么?如果要改进的话,你有什么想法?
|
||||
|
||||
```
|
||||
else if (fd > FD_INIT_SIZE) {
|
||||
error(1, 0, "too many connections");
|
||||
close(fd);
|
||||
|
||||
```
|
||||
|
||||
第二道,你可以仔细阅读一下数据读写部分Buffer的代码,你觉得用一个Buffer对象,而不是两个的目的是什么?
|
||||
|
||||
欢迎在评论区写下你的思考,我会和你一起交流,也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。
|
||||
365
极客时间专栏/网络编程实战/第三模块:性能篇/23 | Linux利器:epoll的前世今生.md
Normal file
365
极客时间专栏/网络编程实战/第三模块:性能篇/23 | Linux利器:epoll的前世今生.md
Normal 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_create,epoll_ctl和epoll_wait。接下来我对这几个API详细展开讲一下。
|
||||
|
||||
### epoll_create
|
||||
|
||||
```
|
||||
int epoll_create(int size);
|
||||
int epoll_create1(int flags);
|
||||
返回值: 若成功返回一个大于0的值,表示epoll实例;若返回-1表示出错
|
||||
|
||||
```
|
||||
|
||||
epoll_create()方法创建了一个epoll实例,从Linux 2.6.8开始,参数size被自动忽略,但是该值仍需要一个大于0的整数。这个epoll实例被用来调用epoll_ctl和epoll_wait,如果这个epoll实例不再需要,比如服务器正常关机,需要调用close()方法释放epoll实例,这样系统内核可以回收epoll实例所分配使用的内核资源。
|
||||
|
||||
关于这个参数size,在一开始的epoll_create实现中,是用来告知内核期望监控的文件描述字大小,然后内核使用这部分的信息来初始化内核数据结构,在新的实现中,这个参数不再被需要,因为内核可以动态分配需要的内核数据结构。我们只需要注意,每次将size设置成一个大于0的整数就可以了。
|
||||
|
||||
epoll_create1()的用法和epoll_create()基本一致,如果epoll_create1()的输入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 "lib/common.h"
|
||||
|
||||
#define MAXEVENTS 128
|
||||
|
||||
char rot13_char(char c) {
|
||||
if ((c >= 'a' && c <= 'm') || (c >= 'A' && c <= 'M'))
|
||||
return c + 13;
|
||||
else if ((c >= 'n' && c <= 'z') || (c >= 'N' && c <= 'Z'))
|
||||
return c - 13;
|
||||
else
|
||||
return c;
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
int listen_fd, socket_fd;
|
||||
int n, i;
|
||||
int efd;
|
||||
struct epoll_event event;
|
||||
struct epoll_event *events;
|
||||
|
||||
listen_fd = tcp_nonblocking_server_listen(SERV_PORT);
|
||||
|
||||
efd = epoll_create1(0);
|
||||
if (efd == -1) {
|
||||
error(1, errno, "epoll create failed");
|
||||
}
|
||||
|
||||
event.data.fd = listen_fd;
|
||||
event.events = EPOLLIN | EPOLLET;
|
||||
if (epoll_ctl(efd, EPOLL_CTL_ADD, listen_fd, &event) == -1) {
|
||||
error(1, errno, "epoll_ctl add listen fd failed");
|
||||
}
|
||||
|
||||
/* Buffer where events are returned */
|
||||
events = calloc(MAXEVENTS, sizeof(event));
|
||||
|
||||
while (1) {
|
||||
n = epoll_wait(efd, events, MAXEVENTS, -1);
|
||||
printf("epoll_wait wakeup\n");
|
||||
for (i = 0; i < n; i++) {
|
||||
if ((events[i].events & EPOLLERR) ||
|
||||
(events[i].events & EPOLLHUP) ||
|
||||
(!(events[i].events & EPOLLIN))) {
|
||||
fprintf(stderr, "epoll error\n");
|
||||
close(events[i].data.fd);
|
||||
continue;
|
||||
} else if (listen_fd == events[i].data.fd) {
|
||||
struct sockaddr_storage ss;
|
||||
socklen_t slen = sizeof(ss);
|
||||
int fd = accept(listen_fd, (struct sockaddr *) &ss, &slen);
|
||||
if (fd < 0) {
|
||||
error(1, errno, "accept failed");
|
||||
} else {
|
||||
make_nonblocking(fd);
|
||||
event.data.fd = fd;
|
||||
event.events = EPOLLIN | EPOLLET; //edge-triggered
|
||||
if (epoll_ctl(efd, EPOLL_CTL_ADD, fd, &event) == -1) {
|
||||
error(1, errno, "epoll_ctl add connection fd failed");
|
||||
}
|
||||
}
|
||||
continue;
|
||||
} else {
|
||||
socket_fd = events[i].data.fd;
|
||||
printf("get event on socket fd == %d \n", socket_fd);
|
||||
while (1) {
|
||||
char buf[512];
|
||||
if ((n = read(socket_fd, buf, sizeof(buf))) < 0) {
|
||||
if (errno != EAGAIN) {
|
||||
error(1, errno, "read error");
|
||||
close(socket_fd);
|
||||
}
|
||||
break;
|
||||
} else if (n == 0) {
|
||||
close(socket_fd);
|
||||
break;
|
||||
} else {
|
||||
for (i = 0; i < n; ++i) {
|
||||
buf[i] = rot13_char(buf[i]);
|
||||
}
|
||||
if (write(socket_fd, buf, n) < 0) {
|
||||
error(1, errno, "write error");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
free(events);
|
||||
close(listen_fd);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
程序的第23行调用epoll_create0创建了一个epoll实例。
|
||||
|
||||
28-32行,调用epoll_ctl将监听套接字对应的I/O事件进行了注册,这样在有新的连接建立之后,就可以感知到。注意这里使用的是edge-triggered(边缘触发)。
|
||||
|
||||
35行为返回的event数组分配了内存。
|
||||
|
||||
主循环调用epoll_wait函数分发I/O事件,当epoll_wait成功返回时,通过遍历返回的event数组,就直接可以知道发生的I/O事件。
|
||||
|
||||
第41-46行判断了各种错误情况。
|
||||
|
||||
第47-61行是监听套接字上有事件发生的情况下,调用accept获取已建立连接,并将该连接设置为非阻塞,再调用epoll_ctl把已连接套接字对应的可读事件注册到epoll实例中。这里我们使用了event_data里面的fd字段,将连接套接字存储其中。
|
||||
|
||||
第63-84行,处理了已连接套接字上的可读事件,读取字节流,编码后再回应给客户端。
|
||||
|
||||
### 实验
|
||||
|
||||
启动该服务器:
|
||||
|
||||
```
|
||||
$./epoll01
|
||||
epoll_wait wakeup
|
||||
epoll_wait wakeup
|
||||
epoll_wait wakeup
|
||||
get event on socket fd == 6
|
||||
epoll_wait wakeup
|
||||
get event on socket fd == 5
|
||||
epoll_wait wakeup
|
||||
get event on socket fd == 5
|
||||
epoll_wait wakeup
|
||||
get event on socket fd == 6
|
||||
epoll_wait wakeup
|
||||
get event on socket fd == 6
|
||||
epoll_wait wakeup
|
||||
get event on socket fd == 6
|
||||
epoll_wait wakeup
|
||||
get event on socket fd == 5
|
||||
|
||||
```
|
||||
|
||||
再启动几个telnet客户端,可以看到有连接建立情况下,epoll_wait迅速从挂起状态结束;并且套接字上有数据可读时,epoll_wait也迅速结束挂起状态,这时候通过read可以读取套接字接收缓冲区上的数据。
|
||||
|
||||
```
|
||||
$telnet 127.0.0.1 43211
|
||||
Trying 127.0.0.1...
|
||||
Connected to 127.0.0.1.
|
||||
Escape character is '^]'.
|
||||
fasfsafas
|
||||
snfsfnsnf
|
||||
^]
|
||||
telnet> quit
|
||||
Connection closed.
|
||||
|
||||
```
|
||||
|
||||
## edge-triggered VS level-triggered
|
||||
|
||||
对于edge-triggered和level-triggered, 官方的说法是一个是边缘触发,一个是条件触发。也有文章从电子脉冲角度来解读的,总体上,给初学者的带来的感受是理解上有困难。
|
||||
|
||||
这里有两个程序,我们用这个程序来说明一下这两者之间的不同。
|
||||
|
||||
在这两个程序里,即使已连接套接字上有数据可读,我们也不调用read函数去读,只是简单地打印出一句话。
|
||||
|
||||
第一个程序我们设置为edge-triggered,即边缘触发。开启这个服务器程序,用telnet连接上,输入一些字符,我们看到,服务器端只从epoll_wait中苏醒过一次,就是第一次有数据可读的时候。
|
||||
|
||||
```
|
||||
$./epoll02
|
||||
epoll_wait wakeup
|
||||
epoll_wait wakeup
|
||||
get event on socket fd == 5
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
$telnet 127.0.0.1 43211
|
||||
Trying 127.0.0.1...
|
||||
Connected to 127.0.0.1.
|
||||
Escape character is '^]'.
|
||||
asfafas
|
||||
|
||||
```
|
||||
|
||||
第二个程序我们设置为level-triggered,即条件触发。然后按照同样的步骤来一次,观察服务器端,这一次我们可以看到,服务器端不断地从epoll_wait中苏醒,告诉我们有数据需要读取。
|
||||
|
||||
```
|
||||
$./epoll03
|
||||
epoll_wait wakeup
|
||||
epoll_wait wakeup
|
||||
get event on socket fd == 5
|
||||
epoll_wait wakeup
|
||||
get event on socket fd == 5
|
||||
epoll_wait wakeup
|
||||
get event on socket fd == 5
|
||||
epoll_wait wakeup
|
||||
get event on socket fd == 5
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
这就是两者的区别,条件触发的意思是只要满足事件的条件,比如有数据需要读,就一直不断地把这个事件传递给用户;而边缘触发的意思是只有第一次满足条件的时候才触发,之后就不会再传递同样的事件了。
|
||||
|
||||
一般我们认为,边缘触发的效率比条件触发的效率要高,这一点也是epoll的杀手锏之一。
|
||||
|
||||
## epoll的历史
|
||||
|
||||
早在Linux实现epoll之前,Windows系统就已经在1994年引入了IOCP,这是一个异步I/O模型,用来支持高并发的网络I/O,而著名的FreeBSD在2000年引入了Kqueue——一个I/O事件分发框架。
|
||||
|
||||
Linux在2002年引入了epoll,不过相关工作的讨论和设计早在2000年就开始了。如果你感兴趣的话,可以[http://lkml.iu.edu/hypermail/linux/kernel/0010.3/0003.html](http:// <a href=)">点击这里看一下里面的讨论。
|
||||
|
||||
为什么Linux不把FreeBSD的kqueue直接移植过来,而是另辟蹊径创立了epoll呢?
|
||||
|
||||
让我们先看下kqueue的用法,kqueue也需要先创建一个名叫kqueue的对象,然后通过这个对象,调用kevent函数增加感兴趣的事件,同时,也是通过这个kevent函数来等待事件的发生。
|
||||
|
||||
```
|
||||
int kqueue(void);
|
||||
int kevent(int kq, const struct kevent *changelist, int nchanges,
|
||||
struct kevent *eventlist, int nevents,
|
||||
const struct timespec *timeout);
|
||||
void EV_SET(struct kevent *kev, uintptr_t ident, short filter,
|
||||
u_short flags, u_int fflags, intptr_t data, void *udata);
|
||||
|
||||
struct kevent {
|
||||
uintptr_t ident; /* identifier (e.g., file descriptor) */
|
||||
short filter; /* filter type (e.g., EVFILT_READ) */
|
||||
u_short flags; /* action flags (e.g., EV_ADD) */
|
||||
u_int fflags; /* filter-specific flags */
|
||||
intptr_t data; /* filter-specific data */
|
||||
void *udata; /* opaque user data */
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
Linus在他最初的设想里,提到了这么一句话,也就是说他觉得类似select或poll的数组方式是可以的,而队列方式则是不可取的。
|
||||
|
||||
So sticky arrays of events are good, while queues are bad. Let’s take that as one of the fundamentals.
|
||||
|
||||
在最初的设计里,Linus等于把keque里面的kevent函数拆分了两个部分,一部分负责事件绑定,通过bind_event函数来实现;另一部分负责事件等待,通过get_events来实现。
|
||||
|
||||
```
|
||||
struct event {
|
||||
unsigned long id; /* file descriptor ID the event is on */
|
||||
unsigned long event; /* bitmask of active events */
|
||||
};
|
||||
|
||||
int bind_event(int fd, struct event *event);
|
||||
int get_events(struct event * event_array, int maxnr, struct timeval *tmout);
|
||||
|
||||
```
|
||||
|
||||
和最终的epoll实现相比,前者类似epoll_ctl,后者类似epoll_wait,不过原始的设计里没有考虑到创建epoll句柄,在最终的实现里增加了epoll_create,支持了epoll句柄的创建。
|
||||
|
||||
2002年,epoll最终在Linux 2.5.44中首次出现,在2.6中趋于稳定,为Linux的高性能网络I/O画上了一段句号。
|
||||
|
||||
## 总结
|
||||
|
||||
Linux中epoll的出现,为高性能网络编程补齐了最后一块拼图。epoll通过改进的接口设计,避免了用户态-内核态频繁的数据拷贝,大大提高了系统性能。在使用epoll的时候,我们一定要理解条件触发和边缘触发两种模式。条件触发的意思是只要满足事件的条件,比如有数据需要读,就一直不断地把这个事件传递给用户;而边缘触发的意思是只有第一次满足条件的时候才触发,之后就不会再传递同样的事件了。
|
||||
|
||||
## 思考题
|
||||
|
||||
理解完了epoll,和往常一样,我给你布置两道思考题:
|
||||
|
||||
第一道,你不妨试着修改一下第20讲中select的例子,即在已连接套接字上有数据可读,也不调用read函数去读,看一看你的结果,你认为select是边缘触发的,还是条件触发的?
|
||||
|
||||
第二道,同样的修改一下第21讲poll的例子,看看你的结果,你认为poll是边缘触发的,还是条件触发的?
|
||||
|
||||
你可以在GitHub上上传你的代码,并写出你的疑惑,我会和你一起交流,也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。
|
||||
227
极客时间专栏/网络编程实战/第三模块:性能篇/24 | C10K问题:高并发模型设计.md
Normal file
227
极客时间专栏/网络编程实战/第三模块:性能篇/24 | C10K问题:高并发模型设计.md
Normal 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=)">这个页面来获得最新有关这方面的信息。
|
||||
|
||||
## 操作系统层面
|
||||
|
||||
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上的kqueue,Windows上的IOCP,Solaris上的/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问题,即单机处理千万级并发,你认为能实现吗?挑战和瓶颈又在哪里呢?
|
||||
|
||||
欢迎你在评论区写下你对这两个问题的思考,我会和你一起交流,也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。
|
||||
209
极客时间专栏/网络编程实战/第三模块:性能篇/25 | 使用阻塞I|O和进程模型:最传统的方式.md
Normal file
209
极客时间专栏/网络编程实战/第三模块:性能篇/25 | 使用阻塞I|O和进程模型:最传统的方式.md
Normal 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 "lib/common.h"
|
||||
|
||||
#define MAX_LINE 4096
|
||||
|
||||
char rot13_char(char c) {
|
||||
if ((c >= 'a' && c <= 'm') || (c >= 'A' && c <= 'M'))
|
||||
return c + 13;
|
||||
else if ((c >= 'n' && c <= 'z') || (c >= 'N' && c <= 'Z'))
|
||||
return c - 13;
|
||||
else
|
||||
return c;
|
||||
}
|
||||
|
||||
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, &ch, 1, 0);
|
||||
if (result == 0) {
|
||||
break;
|
||||
} else if (result == -1) {
|
||||
perror("read");
|
||||
break;
|
||||
}
|
||||
|
||||
if (outbuf_used < 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) > 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 *) &ss, &slen);
|
||||
if (fd < 0) {
|
||||
error(1, errno, "accept failed");
|
||||
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> quit
|
||||
Connection closed.
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
$telnet 127.0.0.1 43211
|
||||
Trying 127.0.0.1...
|
||||
Connected to localhost.
|
||||
Escape character is '^]'.
|
||||
agasgasg
|
||||
ntnftnft
|
||||
]
|
||||
telnet> quit
|
||||
Connection closed.
|
||||
|
||||
```
|
||||
|
||||
客户端退出,服务器端也在正常工作,此时如果再通过telnet建立新的连接,客户端和服务器端的数据传输也会正常进行。
|
||||
|
||||
至此,我们构建了一个完整的服务器端程序,可以并发处理多个不同的客户连接,互不干扰。
|
||||
|
||||
## 总结
|
||||
|
||||
使用阻塞I/O和进程模型,为每一个连接创建一个独立的子进程来进行服务,是一个非常简单有效的实现方式,这种方式可能很难满足高性能程序的需求,但好处在于实现简单。在实现这样的程序时,我们需要注意两点:
|
||||
|
||||
- 要注意对套接字的关闭梳理;
|
||||
- 要注意对子进程进行回收,避免产生不必要的僵尸进程。
|
||||
|
||||
## 思考题
|
||||
|
||||
给你出两道思考题:
|
||||
|
||||
第一道,你可以查查资料,看看有没有比较著名的程序是使用这样的模式来构建的?
|
||||
|
||||
第二道,程序中处理SIGCHLD信号时,使用了一个循环来回收处理终止的子进程,为什么要这么做呢?如果不使用循环会有什么后果?
|
||||
|
||||
欢迎你在评论区写下你的思考,我会和你一起交流,也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。
|
||||
387
极客时间专栏/网络编程实战/第三模块:性能篇/26 | 使用阻塞I|O和线程模型:换一种轻量的方式.md
Normal file
387
极客时间专栏/网络编程实战/第三模块:性能篇/26 | 使用阻塞I|O和线程模型:换一种轻量的方式.md
Normal 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),包括一个可以唯一标识线程的ID(thread ID,或者叫tid)、栈、程序计数器、寄存器等。在同一个进程中,所有的线程共享该进程的整个虚拟地址空间,包括代码、数据、堆、共享库等。
|
||||
|
||||
在前面的程序中,我们没有显式使用线程,但这不代表线程没有发挥作用。实际上,每个进程一开始都会产生一个线程,一般被称为主线程,主线程可以再产生子线程,这样的主线程-子线程对可以叫做一个对等线程。
|
||||
|
||||
你可能会问,既然可以使用多进程来处理并发,为什么还要使用多线程模型呢?
|
||||
|
||||
简单来说,在同一个进程下,线程上下文切换的开销要比进程小得多。怎么理解线程上下文呢?我们的代码被CPU执行的时候,是需要一些数据支撑的,比如程序计数器告诉CPU代码执行到哪里了,寄存器里存了当前计算的一些中间值,内存里放置了一些当前用到的变量等,从一个计算场景,切换到另外一个计算场景,程序计数器、寄存器等这些值重新载入新场景的值,就是线程的上下文切换。
|
||||
|
||||
## POSIX线程模型
|
||||
|
||||
POSIX线程是现代UNIX系统提供的处理线程的标准接口。POSIX定义的线程函数大约有60多个,这些函数可以帮助我们创建线程、回收线程。接下来我们先看一个简单的例子程序。
|
||||
|
||||
```
|
||||
int another_shared = 0;
|
||||
|
||||
void thread_run(void *arg) {
|
||||
int *calculator = (int *) arg;
|
||||
printf("hello, world, tid == %d \n", pthread_self());
|
||||
for (int i = 0; i < 1000; i++) {
|
||||
*calculator += 1;
|
||||
another_shared += 1;
|
||||
}
|
||||
}
|
||||
|
||||
int main(int c, char **v) {
|
||||
int calculator;
|
||||
|
||||
pthread_t tid1;
|
||||
pthread_t tid2;
|
||||
|
||||
pthread_create(&tid1, NULL, thread_run, &calculator);
|
||||
pthread_create(&tid2, NULL, thread_run, &calculator);
|
||||
|
||||
pthread_join(tid1, NULL);
|
||||
pthread_join(tid2, NULL);
|
||||
|
||||
printf("calculator is %d \n", calculator);
|
||||
printf("another_shared is %d \n", 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值
|
||||
|
||||
```
|
||||
|
||||
每个线程都有一个线程ID(tid)唯一来标识,其数据类型为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 "lib/common.h"
|
||||
|
||||
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 *) &ss, &slen);
|
||||
if (fd < 0) {
|
||||
error(1, errno, "accept failed");
|
||||
} else {
|
||||
pthread_create(&tid, NULL, &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 >= 'a' && c <= 'm') || (c >= 'A' && c <= 'M'))
|
||||
return c + 13;
|
||||
else if ((c >= 'n' && c <= 'z') || (c >= 'N' && c <= 'Z'))
|
||||
return c - 13;
|
||||
else
|
||||
return c;
|
||||
}
|
||||
|
||||
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, &ch, 1, 0);
|
||||
|
||||
//断开连接或者出错
|
||||
if (result == 0) {
|
||||
break;
|
||||
} else if (result == -1) {
|
||||
error(1, errno, "read error");
|
||||
break;
|
||||
}
|
||||
|
||||
if (outbuf_used < 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> 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->number = number;
|
||||
blockQueue->fd = calloc(number, sizeof(int));
|
||||
blockQueue->front = blockQueue->rear = 0;
|
||||
pthread_mutex_init(&blockQueue->mutex, NULL);
|
||||
pthread_cond_init(&blockQueue->cond, NULL);
|
||||
}
|
||||
|
||||
//往队列里放置一个描述字fd
|
||||
void block_queue_push(block_queue *blockQueue, int fd) {
|
||||
//一定要先加锁,因为有多个线程需要读写队列
|
||||
pthread_mutex_lock(&blockQueue->mutex);
|
||||
//将描述字放到队列尾的位置
|
||||
blockQueue->fd[blockQueue->rear] = fd;
|
||||
//如果已经到最后,重置尾的位置
|
||||
if (++blockQueue->rear == blockQueue->number) {
|
||||
blockQueue->rear = 0;
|
||||
}
|
||||
printf("push fd %d", fd);
|
||||
//通知其他等待读的线程,有新的连接字等待处理
|
||||
pthread_cond_signal(&blockQueue->cond);
|
||||
//解锁
|
||||
pthread_mutex_unlock(&blockQueue->mutex);
|
||||
}
|
||||
|
||||
//从队列里读出描述字进行处理
|
||||
int block_queue_pop(block_queue *blockQueue) {
|
||||
//加锁
|
||||
pthread_mutex_lock(&blockQueue->mutex);
|
||||
//判断队列里没有新的连接字可以处理,就一直条件等待,直到有新的连接字入队列
|
||||
while (blockQueue->front == blockQueue->rear)
|
||||
pthread_cond_wait(&blockQueue->cond, &blockQueue->mutex);
|
||||
//取出队列头的连接字
|
||||
int fd = blockQueue->fd[blockQueue->front];
|
||||
//如果已经到最后,重置头的位置
|
||||
if (++blockQueue->front == blockQueue->number) {
|
||||
blockQueue->front = 0;
|
||||
}
|
||||
printf("pop fd %d", fd);
|
||||
//解锁
|
||||
pthread_mutex_unlock(&blockQueue->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("get fd in thread, fd==%d, tid == %d", 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(&blockQueue, BLOCK_QUEUE_SIZE);
|
||||
|
||||
thread_array = calloc(THREAD_NUMBER, sizeof(Thread));
|
||||
int i;
|
||||
for (i = 0; i < THREAD_NUMBER; i++) {
|
||||
pthread_create(&(thread_array[i].thread_tid), NULL, &thread_run, (void *) &blockQueue);
|
||||
}
|
||||
|
||||
while (1) {
|
||||
struct sockaddr_storage ss;
|
||||
socklen_t slen = sizeof(ss);
|
||||
int fd = accept(listener_fd, (struct sockaddr *) &ss, &slen);
|
||||
if (fd < 0) {
|
||||
error(1, errno, "accept failed");
|
||||
} else {
|
||||
block_queue_push(&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> quit
|
||||
Connection closed.
|
||||
|
||||
```
|
||||
|
||||
和前面的程序相比,线程创建和销毁的开销大大降低,但因为线程池大小固定,又因为使用了阻塞套接字,肯定会出现有连接得不到及时服务的场景。这个问题的解决还是要回到我在开篇词里提到的方案上来,多路I/O复用加上线程来处理,仅仅使用阻塞I/O模型和线程是没有办法达到极致的高并发处理能力。
|
||||
|
||||
## 总结
|
||||
|
||||
这一讲,我们使用了线程来构建服务器端程序。一种是每次动态创建线程,另一种是使用线程池提高效率。和进程相比,线程的语义更轻量,使用的场景也更多。线程是高性能网络服务器必须掌握的核心知识,希望你能够通过本讲的学习,牢牢掌握它。
|
||||
|
||||
## 思考题
|
||||
|
||||
和往常一样,给你留两道思考题。
|
||||
|
||||
第一道,连接字队列的实现里,有一个重要情况没有考虑,就是队列里没有可用的位置了,想想看,如何对这种情况进行优化?
|
||||
|
||||
第二道,我在讲到第一个hello-world计数器应用时,说“结果是幸运”这是为什么呢?怎么理解呢?
|
||||
|
||||
欢迎你在评论区写下你的思考,我会和你一起思考,也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。
|
||||
221
极客时间专栏/网络编程实战/第三模块:性能篇/27 | I|O多路复用遇上线程:使用poll单线程处理所有I|O事件.md
Normal file
221
极客时间专栏/网络编程实战/第三模块:性能篇/27 | I|O多路复用遇上线程:使用poll单线程处理所有I|O事件.md
Normal 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 <lib/acceptor.h>
|
||||
#include "lib/common.h"
|
||||
#include "lib/event_loop.h"
|
||||
#include "lib/tcp_server.h"
|
||||
|
||||
char rot13_char(char c) {
|
||||
if ((c >= 'a' && c <= 'm') || (c >= 'A' && c <= 'M'))
|
||||
return c + 13;
|
||||
else if ((c >= 'n' && c <= 'z') || (c >= 'N' && c <= 'Z'))
|
||||
return c - 13;
|
||||
else
|
||||
return c;
|
||||
}
|
||||
|
||||
//连接建立之后的callback
|
||||
int onConnectionCompleted(struct tcp_connection *tcpConnection) {
|
||||
printf("connection completed\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
//数据读到buffer之后的callback
|
||||
int onMessage(struct buffer *input, struct tcp_connection *tcpConnection) {
|
||||
printf("get message from tcp connection %s\n", tcpConnection->name);
|
||||
printf("%s", input->data);
|
||||
|
||||
struct buffer *output = buffer_new();
|
||||
int size = buffer_readable_size(input);
|
||||
for (int i = 0; i < size; i++) {
|
||||
buffer_append_char(output, rot13_char(buffer_read_char(input)));
|
||||
}
|
||||
tcp_connection_send_buffer(tcpConnection, output);
|
||||
return 0;
|
||||
}
|
||||
|
||||
//数据通过buffer写完之后的callback
|
||||
int onWriteCompleted(struct tcp_connection *tcpConnection) {
|
||||
printf("write completed\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
//连接关闭之后的callback
|
||||
int onConnectionClosed(struct tcp_connection *tcpConnection) {
|
||||
printf("connection closed\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
int main(int c, char **v) {
|
||||
//主线程event_loop
|
||||
struct event_loop *eventLoop = event_loop_init();
|
||||
|
||||
//初始化acceptor
|
||||
struct acceptor *acceptor = acceptor_init(SERV_PORT);
|
||||
|
||||
//初始tcp_server,可以指定线程数目,如果线程是0,就只有一个线程,既负责acceptor,也负责I/O
|
||||
struct TCPserver *tcpServer = tcp_server_init(eventLoop, acceptor, onConnectionCompleted, onMessage,
|
||||
onWriteCompleted, onConnectionClosed, 0);
|
||||
tcp_server_start(tcpServer);
|
||||
|
||||
// main thread for acceptor
|
||||
event_loop_run(eventLoop);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个程序的main函数部分只有几行, 因为是第一次接触到,稍微展开介绍一下。
|
||||
|
||||
第49行创建了一个event_loop,即reactor对象,这个event_loop和线程相关联,每个event_loop在线程里执行的是一个无限循环,以便完成事件的分发。
|
||||
|
||||
第52行初始化了acceptor,用来监听在某个端口上。
|
||||
|
||||
第55行创建了一个TCPServer,创建的时候可以指定线程数目,这里线程是0,就只有一个线程,既负责acceptor的连接处理,也负责已连接套接字的I/O处理。这里比较重要的是传入了几个回调函数,分别对应了连接建立完成、数据读取完成、数据发送完成、连接关闭完成几种操作,通过回调函数,让业务程序可以聚焦在业务层开发。
|
||||
|
||||
第57行开启监听。
|
||||
|
||||
第60行运行event_loop无限循环,等待acceptor上有连接建立、新连接上有数据可读等。
|
||||
|
||||
## 样例程序结果
|
||||
|
||||
运行这个服务器程序,开启两个telnet客户端,我们看到服务器端的输出如下:
|
||||
|
||||
```
|
||||
$./poll-server-onethread
|
||||
[msg] set poll as dispatcher
|
||||
[msg] add channel fd == 4, main thread
|
||||
[msg] poll added channel fd==4
|
||||
[msg] add channel fd == 5, main thread
|
||||
[msg] poll added channel fd==5
|
||||
[msg] event loop run, main thread
|
||||
[msg] get message channel i==1, fd==5
|
||||
[msg] activate channel fd == 5, revents=2, main thread
|
||||
[msg] new connection established, socket == 6
|
||||
connection completed
|
||||
[msg] add channel fd == 6, main thread
|
||||
[msg] poll added channel fd==6
|
||||
[msg] get message channel i==2, fd==6
|
||||
[msg] activate channel fd == 6, revents=2, main thread
|
||||
get message from tcp connection connection-6
|
||||
afadsfaf
|
||||
[msg] get message channel i==2, fd==6
|
||||
[msg] activate channel fd == 6, revents=2, main thread
|
||||
get message from tcp connection connection-6
|
||||
afadsfaf
|
||||
fdafasf
|
||||
[msg] get message channel i==1, fd==5
|
||||
[msg] activate channel fd == 5, revents=2, main thread
|
||||
[msg] new connection established, socket == 7
|
||||
connection completed
|
||||
[msg] add channel fd == 7, main thread
|
||||
[msg] poll added channel fd==7
|
||||
[msg] get message channel i==3, fd==7
|
||||
[msg] activate channel fd == 7, revents=2, main thread
|
||||
get message from tcp connection connection-7
|
||||
sfasggwqe
|
||||
[msg] get message channel i==3, fd==7
|
||||
[msg] activate channel fd == 7, revents=2, main thread
|
||||
[msg] poll delete channel fd==7
|
||||
connection closed
|
||||
[msg] get message channel i==2, fd==6
|
||||
[msg] activate channel fd == 6, revents=2, main thread
|
||||
[msg] poll delete channel fd==6
|
||||
connection closed
|
||||
|
||||
```
|
||||
|
||||
这里自始至终都只有一个main thread在工作,可见,单线程的reactor处理多个连接时也可以表现良好。
|
||||
|
||||
## 总结
|
||||
|
||||
这一讲我们总结了几种不同的I/O模型和线程模型设计,并比较了各自不同的优缺点。从这一讲开始,我们将使用自己编写的编程框架来完成业务开发,这一讲使用了poll来处理所有的I/O事件,在下一讲里,我们将会看到如何把acceptor的连接事件和已连接套接字的I/O事件交由不同的线程处理,而这个分离,不过是在应用程序层简单的参数配置而已。
|
||||
|
||||
## 思考题
|
||||
|
||||
和往常一样,给你留两道思考题:
|
||||
|
||||
1. 你可以试着修改一下onMessage方法,把它变为期中作业中提到的cd、ls等command实现。
|
||||
1. 文章里服务器端的decode-compute-encode是在哪里实现的?你有什么办法来解决业务逻辑和I/O逻辑混在一起么?
|
||||
|
||||
欢迎你在评论区写下你的思考,或者在GitHub上上传你的代码,也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。
|
||||
299
极客时间专栏/网络编程实战/第三模块:性能篇/28 | I|O多路复用进阶:子线程使用poll处理连接I|O事件.md
Normal file
299
极客时间专栏/网络编程实战/第三模块:性能篇/28 | I|O多路复用进阶:子线程使用poll处理连接I|O事件.md
Normal 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("port", SSL? "8992" : "8023"));
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
//产生一个reactor线程,只负责accetpor的对应处理
|
||||
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
|
||||
//产生一个reactor线程,负责处理已连接套接字的I/O事件分发
|
||||
EventLoopGroup workerGroup = new NioEventLoopGroup(1);
|
||||
try {
|
||||
//标准的Netty初始,通过serverbootstrap完成线程池、channel以及对应的handler设置,注意这里讲bossGroup和workerGroup作为参数设置
|
||||
ServerBootstrap b = new ServerBootstrap();
|
||||
b.group(bossGroup, workerGroup)
|
||||
.channel(NioServerSocketChannel.class)
|
||||
.handler(new LoggingHandler(LogLevel.INFO))
|
||||
.childHandler(new TelnetServerInitializer(sslCtx));
|
||||
|
||||
//开启两个reactor线程无限循环处理
|
||||
b.bind(PORT).sync().channel().closeFuture().sync();
|
||||
} finally {
|
||||
bossGroup.shutdownGracefully();
|
||||
workerGroup.shutdownGracefully();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
<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 <lib/acceptor.h>
|
||||
#include "lib/common.h"
|
||||
#include "lib/event_loop.h"
|
||||
#include "lib/tcp_server.h"
|
||||
|
||||
char rot13_char(char c) {
|
||||
if ((c >= 'a' && c <= 'm') || (c >= 'A' && c <= 'M'))
|
||||
return c + 13;
|
||||
else if ((c >= 'n' && c <= 'z') || (c >= 'N' && c <= 'Z'))
|
||||
return c - 13;
|
||||
else
|
||||
return c;
|
||||
}
|
||||
|
||||
//连接建立之后的callback
|
||||
int onConnectionCompleted(struct tcp_connection *tcpConnection) {
|
||||
printf("connection completed\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
//数据读到buffer之后的callback
|
||||
int onMessage(struct buffer *input, struct tcp_connection *tcpConnection) {
|
||||
printf("get message from tcp connection %s\n", tcpConnection->name);
|
||||
printf("%s", input->data);
|
||||
|
||||
struct buffer *output = buffer_new();
|
||||
int size = buffer_readable_size(input);
|
||||
for (int i = 0; i < size; i++) {
|
||||
buffer_append_char(output, rot13_char(buffer_read_char(input)));
|
||||
}
|
||||
tcp_connection_send_buffer(tcpConnection, output);
|
||||
return 0;
|
||||
}
|
||||
|
||||
//数据通过buffer写完之后的callback
|
||||
int onWriteCompleted(struct tcp_connection *tcpConnection) {
|
||||
printf("write completed\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
//连接关闭之后的callback
|
||||
int onConnectionClosed(struct tcp_connection *tcpConnection) {
|
||||
printf("connection closed\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
int main(int c, char **v) {
|
||||
//主线程event_loop
|
||||
struct event_loop *eventLoop = event_loop_init();
|
||||
|
||||
//初始化acceptor
|
||||
struct acceptor *acceptor = acceptor_init(SERV_PORT);
|
||||
|
||||
//初始tcp_server,可以指定线程数目,这里线程是4,说明是一个acceptor线程,4个I/O线程,没一个I/O线程
|
||||
//tcp_server自己带一个event_loop
|
||||
struct TCPserver *tcpServer = tcp_server_init(eventLoop, acceptor, onConnectionCompleted, onMessage,
|
||||
onWriteCompleted, onConnectionClosed, 4);
|
||||
tcp_server_start(tcpServer);
|
||||
|
||||
// main thread for acceptor
|
||||
event_loop_run(eventLoop);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们的样例程序几乎和第27讲的一样,唯一的不同是在创建TCPServer时,线程的数量设置不再是0,而是4。这里线程是4,说明是一个主acceptor线程,4个从reactor线程,每一个线程都跟一个event_loop一一绑定。
|
||||
|
||||
你可能会问,这么简单就完成了主、从线程的配置?
|
||||
|
||||
答案是YES。这其实是设计框架需要考虑的地方,一个框架不仅要考虑性能、扩展性,也需要考虑可用性。可用性部分就是程序开发者如何使用框架。如果我是一个开发者,我肯定关心框架的使用方式是不是足够方便,配置是不是足够灵活等。
|
||||
|
||||
像这里,可以根据需求灵活地配置主、从反应堆线程,就是一个易用性的体现。当然,因为时间有限,我没有考虑woker线程的部分,这部分其实应该是应用程序自己来设计考虑。网络编程框架通过回调函数暴露了交互的接口,这里应用程序开发者完全可以在onMessage方法里面获取一个子线程来处理encode、compute和encode的工作,像下面的示范代码一样。
|
||||
|
||||
```
|
||||
//数据读到buffer之后的callback
|
||||
int onMessage(struct buffer *input, struct tcp_connection *tcpConnection) {
|
||||
printf("get message from tcp connection %s\n", tcpConnection->name);
|
||||
printf("%s", input->data);
|
||||
//取出一个线程来负责decode、compute和encode
|
||||
struct buffer *output = thread_handle(input);
|
||||
//处理完之后再通过reactor I/O线程发送数据
|
||||
tcp_connection_send_buffer(tcpConnection, output);
|
||||
return
|
||||
|
||||
```
|
||||
|
||||
## 样例程序结果
|
||||
|
||||
我们启动这个服务器端程序,你可以从服务器端的输出上看到使用了poll作为事件分发方式。
|
||||
|
||||
多打开几个telnet客户端交互,main-thread只负责新的连接建立,每个客户端数据的收发由不同的子线程Thread-1、Thread-2、Thread-3和Thread-4来提供服务。
|
||||
|
||||
这里由于使用了子线程进行I/O处理,主线程可以专注于新连接处理,从而大大提高了客户端连接成功率。
|
||||
|
||||
```
|
||||
$./poll-server-multithreads
|
||||
[msg] set poll as dispatcher
|
||||
[msg] add channel fd == 4, main thread
|
||||
[msg] poll added channel fd==4
|
||||
[msg] set poll as dispatcher
|
||||
[msg] add channel fd == 7, main thread
|
||||
[msg] poll added channel fd==7
|
||||
[msg] event loop thread init and signal, Thread-1
|
||||
[msg] event loop run, Thread-1
|
||||
[msg] event loop thread started, Thread-1
|
||||
[msg] set poll as dispatcher
|
||||
[msg] add channel fd == 9, main thread
|
||||
[msg] poll added channel fd==9
|
||||
[msg] event loop thread init and signal, Thread-2
|
||||
[msg] event loop run, Thread-2
|
||||
[msg] event loop thread started, Thread-2
|
||||
[msg] set poll as dispatcher
|
||||
[msg] add channel fd == 11, main thread
|
||||
[msg] poll added channel fd==11
|
||||
[msg] event loop thread init and signal, Thread-3
|
||||
[msg] event loop thread started, Thread-3
|
||||
[msg] set poll as dispatcher
|
||||
[msg] event loop run, Thread-3
|
||||
[msg] add channel fd == 13, main thread
|
||||
[msg] poll added channel fd==13
|
||||
[msg] event loop thread init and signal, Thread-4
|
||||
[msg] event loop run, Thread-4
|
||||
[msg] event loop thread started, Thread-4
|
||||
[msg] add channel fd == 5, main thread
|
||||
[msg] poll added channel fd==5
|
||||
[msg] event loop run, main thread
|
||||
[msg] get message channel i==1, fd==5
|
||||
[msg] activate channel fd == 5, revents=2, main thread
|
||||
[msg] new connection established, socket == 14
|
||||
connection completed
|
||||
[msg] get message channel i==0, fd==7
|
||||
[msg] activate channel fd == 7, revents=2, Thread-1
|
||||
[msg] wakeup, Thread-1
|
||||
[msg] add channel fd == 14, Thread-1
|
||||
[msg] poll added channel fd==14
|
||||
[msg] get message channel i==1, fd==14
|
||||
[msg] activate channel fd == 14, revents=2, Thread-1
|
||||
get message from tcp connection connection-14
|
||||
fasfas
|
||||
[msg] get message channel i==1, fd==14
|
||||
[msg] activate channel fd == 14, revents=2, Thread-1
|
||||
get message from tcp connection connection-14
|
||||
fasfas
|
||||
asfa
|
||||
[msg] get message channel i==1, fd==5
|
||||
[msg] activate channel fd == 5, revents=2, main thread
|
||||
[msg] new connection established, socket == 15
|
||||
connection completed
|
||||
[msg] get message channel i==0, fd==9
|
||||
[msg] activate channel fd == 9, revents=2, Thread-2
|
||||
[msg] wakeup, Thread-2
|
||||
[msg] add channel fd == 15, Thread-2
|
||||
[msg] poll added channel fd==15
|
||||
[msg] get message channel i==1, fd==15
|
||||
[msg] activate channel fd == 15, revents=2, Thread-2
|
||||
get message from tcp connection connection-15
|
||||
afasdfasf
|
||||
[msg] get message channel i==1, fd==15
|
||||
[msg] activate channel fd == 15, revents=2, Thread-2
|
||||
get message from tcp connection connection-15
|
||||
afasdfasf
|
||||
safsafa
|
||||
[msg] get message channel i==1, fd==15
|
||||
[msg] activate channel fd == 15, revents=2, Thread-2
|
||||
[msg] poll delete channel fd==15
|
||||
connection closed
|
||||
[msg] get message channel i==1, fd==5
|
||||
[msg] activate channel fd == 5, revents=2, main thread
|
||||
[msg] new connection established, socket == 16
|
||||
connection completed
|
||||
[msg] get message channel i==0, fd==11
|
||||
[msg] activate channel fd == 11, revents=2, Thread-3
|
||||
[msg] wakeup, Thread-3
|
||||
[msg] add channel fd == 16, Thread-3
|
||||
[msg] poll added channel fd==16
|
||||
[msg] get message channel i==1, fd==16
|
||||
[msg] activate channel fd == 16, revents=2, Thread-3
|
||||
get message from tcp connection connection-16
|
||||
fdasfasdf
|
||||
[msg] get message channel i==1, fd==14
|
||||
[msg] activate channel fd == 14, revents=2, Thread-1
|
||||
[msg] poll delete channel fd==14
|
||||
connection closed
|
||||
[msg] get message channel i==1, fd==5
|
||||
[msg] activate channel fd == 5, revents=2, main thread
|
||||
[msg] new connection established, socket == 17
|
||||
connection completed
|
||||
[msg] get message channel i==0, fd==13
|
||||
[msg] activate channel fd == 13, revents=2, Thread-4
|
||||
[msg] wakeup, Thread-4
|
||||
[msg] add channel fd == 17, Thread-4
|
||||
[msg] poll added channel fd==17
|
||||
[msg] get message channel i==1, fd==17
|
||||
[msg] activate channel fd == 17, revents=2, Thread-4
|
||||
get message from tcp connection connection-17
|
||||
qreqwrq
|
||||
[msg] get message channel i==1, fd==16
|
||||
[msg] activate channel fd == 16, revents=2, Thread-3
|
||||
[msg] poll delete channel fd==16
|
||||
connection closed
|
||||
[msg] get message channel i==1, fd==5
|
||||
[msg] activate channel fd == 5, revents=2, main thread
|
||||
[msg] new connection established, socket == 18
|
||||
connection completed
|
||||
[msg] get message channel i==0, fd==7
|
||||
[msg] activate channel fd == 7, revents=2, Thread-1
|
||||
[msg] wakeup, Thread-1
|
||||
[msg] add channel fd == 18, Thread-1
|
||||
[msg] poll added channel fd==18
|
||||
[msg] get message channel i==1, fd==18
|
||||
[msg] activate channel fd == 18, revents=2, Thread-1
|
||||
get message from tcp connection connection-18
|
||||
fasgasdg
|
||||
^C
|
||||
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
本讲主要讲述了主从reactor模式,主从reactor模式中,主reactor只负责连接建立的处理,而把已连接套接字的I/O事件分发交给从reactor线程处理,这大大提高了客户端连接的处理能力。从Netty的实现上来看,也遵循了这一原则。
|
||||
|
||||
## 思考题
|
||||
|
||||
和往常一样,给你留两道思考题:
|
||||
|
||||
第一道,从日志输出中,你还可以看到main-thread首先加入了fd为4的套接字,这个是监听套接字,很好理解。可是这里的main-thread又加入了一个fd为7的套接字,这个套接字是干什么用的呢?
|
||||
|
||||
第二道,你可以试着修改一下服务器端的代码,把decode-compute-encode部分使用线程或者线程池来处理。
|
||||
|
||||
欢迎你在评论区写下你的思考,或者在GitHub上上传修改过的代码,我会和你一起交流,也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。
|
||||
328
极客时间专栏/网络编程实战/第三模块:性能篇/29 | 渐入佳境:使用epoll和多线程模型.md
Normal file
328
极客时间专栏/网络编程实战/第三模块:性能篇/29 | 渐入佳境:使用epoll和多线程模型.md
Normal 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("set epoll as dispatcher, %s", eventLoop->thread_name);
|
||||
eventLoop->eventDispatcher = &epoll_dispatcher;
|
||||
#else
|
||||
yolanda_msgx("set poll as dispatcher, %s", eventLoop->thread_name);
|
||||
eventLoop->eventDispatcher = &poll_dispatcher;
|
||||
#endif
|
||||
eventLoop->event_dispatcher_data = eventLoop->eventDispatcher->init(eventLoop);
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在根目录下的CMakeLists.txt文件里,引入CheckSymbolExists,如果系统里有epoll_create函数和sys/epoll.h,就自动开启EPOLL_ENABLE。如果没有,EPOLL_ENABLE就不会开启,自动使用poll作为默认的事件分发机制。
|
||||
|
||||
```
|
||||
# check epoll and add config.h for the macro compilation
|
||||
include(CheckSymbolExists)
|
||||
check_symbol_exists(epoll_create "sys/epoll.h" EPOLL_EXISTS)
|
||||
if (EPOLL_EXISTS)
|
||||
# Linux下设置为epoll
|
||||
set(EPOLL_ENABLE 1 CACHE INTERNAL "enable epoll")
|
||||
|
||||
# Linux下也设置为poll
|
||||
# set(EPOLL_ENABLE "" CACHE INTERNAL "not enable epoll")
|
||||
else ()
|
||||
set(EPOLL_ENABLE "" CACHE INTERNAL "not enable epoll")
|
||||
endif ()
|
||||
|
||||
```
|
||||
|
||||
但是,为了能让编译器使用到这个宏,需要让CMake往config.h文件里写入这个宏的最终值,configure_file命令就是起这个作用的。其中config.h.cmake是一个模板文件,已经预先创建在根目录下。同时还需要让编译器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 "sys/epoll.h" EPOLL_EXISTS)
|
||||
if (EPOLL_EXISTS)
|
||||
# Linux下也设置为poll
|
||||
set(EPOLL_ENABLE "" CACHE INTERNAL "not enable epoll")
|
||||
else ()
|
||||
set(EPOLL_ENABLE "" CACHE INTERNAL "not enable epoll")
|
||||
endif (
|
||||
|
||||
```
|
||||
|
||||
不管怎样,现在我们得到了一个Linux下使用epoll作为事件分发的版本,现在让我们使用它来编写程序吧。
|
||||
|
||||
## 样例程序
|
||||
|
||||
我们的样例程序和[第28讲](https://time.geekbang.org/column/article/148148)的一模一样,只是现在我们的事件分发机制从poll切换到了epoll。
|
||||
|
||||
```
|
||||
#include <lib/acceptor.h>
|
||||
#include "lib/common.h"
|
||||
#include "lib/event_loop.h"
|
||||
#include "lib/tcp_server.h"
|
||||
|
||||
char rot13_char(char c) {
|
||||
if ((c >= 'a' && c <= 'm') || (c >= 'A' && c <= 'M'))
|
||||
return c + 13;
|
||||
else if ((c >= 'n' && c <= 'z') || (c >= 'N' && c <= 'Z'))
|
||||
return c - 13;
|
||||
else
|
||||
return c;
|
||||
}
|
||||
|
||||
//连接建立之后的callback
|
||||
int onConnectionCompleted(struct tcp_connection *tcpConnection) {
|
||||
printf("connection completed\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
//数据读到buffer之后的callback
|
||||
int onMessage(struct buffer *input, struct tcp_connection *tcpConnection) {
|
||||
printf("get message from tcp connection %s\n", tcpConnection->name);
|
||||
printf("%s", input->data);
|
||||
|
||||
struct buffer *output = buffer_new();
|
||||
int size = buffer_readable_size(input);
|
||||
for (int i = 0; i < size; i++) {
|
||||
buffer_append_char(output, rot13_char(buffer_read_char(input)));
|
||||
}
|
||||
tcp_connection_send_buffer(tcpConnection, output);
|
||||
return 0;
|
||||
}
|
||||
|
||||
//数据通过buffer写完之后的callback
|
||||
int onWriteCompleted(struct tcp_connection *tcpConnection) {
|
||||
printf("write completed\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
//连接关闭之后的callback
|
||||
int onConnectionClosed(struct tcp_connection *tcpConnection) {
|
||||
printf("connection closed\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
int main(int c, char **v) {
|
||||
//主线程event_loop
|
||||
struct event_loop *eventLoop = event_loop_init();
|
||||
|
||||
//初始化acceptor
|
||||
struct acceptor *acceptor = acceptor_init(SERV_PORT);
|
||||
|
||||
//初始tcp_server,可以指定线程数目,这里线程是4,说明是一个acceptor线程,4个I/O线程,没一个I/O线程
|
||||
//tcp_server自己带一个event_loop
|
||||
struct TCPserver *tcpServer = tcp_server_init(eventLoop, acceptor, onConnectionCompleted, onMessage,
|
||||
onWriteCompleted, onConnectionClosed, 4);
|
||||
tcp_server_start(tcpServer);
|
||||
|
||||
// main thread for acceptor
|
||||
event_loop_run(eventLoop);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
关于这个程序,之前一直没有讲到的部分是缓冲区对象buffer。这其实也是网络编程框架应该考虑的部分。
|
||||
|
||||
我们希望框架可以对应用程序封装掉套接字读和写的部分,转而提供的是针对缓冲区对象的读和写操作。这样一来,从套接字收取数据、处理异常、发送数据等操作都被类似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> 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函数处理要注意什么?
|
||||
|
||||
欢迎你在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,一起交流进步。
|
||||
238
极客时间专栏/网络编程实战/第三模块:性能篇/30 | 真正的大杀器:异步I|O探索.md
Normal file
238
极客时间专栏/网络编程实战/第三模块:性能篇/30 | 真正的大杀器:异步I|O探索.md
Normal 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 "lib/common.h"
|
||||
#include <aio.h>
|
||||
|
||||
const int BUF_SIZE = 512;
|
||||
|
||||
int main() {
|
||||
int err;
|
||||
int result_size;
|
||||
|
||||
// 创建一个临时文件
|
||||
char tmpname[256];
|
||||
snprintf(tmpname, sizeof(tmpname), "/tmp/aio_test_%d", getpid());
|
||||
unlink(tmpname);
|
||||
int fd = open(tmpname, O_CREAT | O_RDWR | O_EXCL, S_IRUSR | S_IWUSR);
|
||||
if (fd == -1) {
|
||||
error(1, errno, "open file failed ");
|
||||
}
|
||||
|
||||
char buf[BUF_SIZE];
|
||||
struct aiocb aiocb;
|
||||
|
||||
//初始化buf缓冲,写入的数据应该为0xfafa这样的,
|
||||
memset(buf, 0xfa, BUF_SIZE);
|
||||
memset(&aiocb, 0, sizeof(struct aiocb));
|
||||
aiocb.aio_fildes = fd;
|
||||
aiocb.aio_buf = buf;
|
||||
aiocb.aio_nbytes = BUF_SIZE;
|
||||
|
||||
//开始写
|
||||
if (aio_write(&aiocb) == -1) {
|
||||
printf(" Error at aio_write(): %s\n", strerror(errno));
|
||||
close(fd);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
//因为是异步的,需要判断什么时候写完
|
||||
while (aio_error(&aiocb) == EINPROGRESS) {
|
||||
printf("writing... \n");
|
||||
}
|
||||
|
||||
//判断写入的是否正确
|
||||
err = aio_error(&aiocb);
|
||||
result_size = aio_return(&aiocb);
|
||||
if (err != 0 || result_size != BUF_SIZE) {
|
||||
printf(" aio_write failed() : %s\n", 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(&cb) == -1) {
|
||||
printf(" air_read failed() : %s\n", strerror(err));
|
||||
close(fd);
|
||||
}
|
||||
|
||||
//因为是异步的,需要判断什么时候读完
|
||||
while (aio_error(&cb) == EINPROGRESS) {
|
||||
printf("Reading... \n");
|
||||
}
|
||||
|
||||
// 判断读是否成功
|
||||
int numBytes = aio_return(&cb);
|
||||
if (numBytes != -1) {
|
||||
printf("Success.\n");
|
||||
} else {
|
||||
printf("Error.\n");
|
||||
}
|
||||
|
||||
// 清理文件句柄
|
||||
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/O,Linux的世界里都发生了什么?
|
||||
1. 在例子程序里,aio_error一直处于占用CPU轮询异步操作的状态,有没有别的方法可以改进一下,比如挂起调用者、设置超时时间等?
|
||||
|
||||
欢迎你在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,一起交流进步一下。
|
||||
831
极客时间专栏/网络编程实战/第三模块:性能篇/31丨性能篇答疑:epoll源码深度剖析.md
Normal file
831
极客时间专栏/网络编程实战/第三模块:性能篇/31丨性能篇答疑:epoll源码深度剖析.md
Normal 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 "private_data" 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->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 "struct epitem" that
|
||||
* happened while transferring ready events to userspace w/out
|
||||
* holding ->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 "rbr" 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 "struct eventpoll"->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 "container" of this item */
|
||||
//当前epollitem所属的eventpoll
|
||||
struct eventpoll *ep;
|
||||
|
||||
/* List header used to link this item to the "struct file" 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 "struct epitem" */
|
||||
struct list_head llink;
|
||||
|
||||
/* The "base" pointer is set to the container "struct epitem" */
|
||||
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 "wait" 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 & ~EPOLL_CLOEXEC)
|
||||
return -EINVAL;
|
||||
/*
|
||||
|
||||
```
|
||||
|
||||
接下来,内核申请分配eventpoll需要的内存空间。
|
||||
|
||||
```
|
||||
/* Create the internal data structure ("struct eventpoll").
|
||||
*/
|
||||
error = ep_alloc(&ep);
|
||||
if (error < 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 & O_CLOEXEC));
|
||||
if (fd < 0) {
|
||||
error = fd;
|
||||
goto out_free_ep;
|
||||
}
|
||||
file = anon_inode_getfile("[eventpoll]", &eventpoll_fops, ep,
|
||||
O_RDWR | (flags & O_CLOEXEC));
|
||||
if (IS_ERR(file)) {
|
||||
error = PTR_ERR(file);
|
||||
goto out_free_fd;
|
||||
}
|
||||
ep->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 "struct file *" 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->f_op->poll)
|
||||
goto error_tgt_fput;
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
如果获得了一个真正的epoll实例句柄,就可以通过private_data获取之前创建的eventpoll实例了。
|
||||
|
||||
```
|
||||
/*
|
||||
* At this point it is safe to assume that the "private_data" contains
|
||||
* our own data structure.
|
||||
*/
|
||||
ep = f.file->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 "mtx"
|
||||
* 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->file > p2->file ? +1:
|
||||
(p1->file < p2->file ? -1 : p1->fd - p2->fd));
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在进行完红黑树查找之后,如果发现是一个ADD操作,并且在树中没有找到对应的二叉树节点,就会调用ep_insert进行二叉树节点的增加。
|
||||
|
||||
```
|
||||
case EPOLL_CTL_ADD:
|
||||
if (!epi) {
|
||||
epds.events |= POLLERR | POLLHUP;
|
||||
error = ep_insert(ep, &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(&ep->user->epoll_watches);
|
||||
if (unlikely(user_watches >= max_user_watches))
|
||||
return -ENOSPC;
|
||||
|
||||
```
|
||||
|
||||
接下来是分配资源和初始化动作。
|
||||
|
||||
```
|
||||
if (!(epi = kmem_cache_alloc(epi_cache, GFP_KERNEL)))
|
||||
return -ENOMEM;
|
||||
|
||||
/* Item initialization follow here ... */
|
||||
INIT_LIST_HEAD(&epi->rdllink);
|
||||
INIT_LIST_HEAD(&epi->fllink);
|
||||
INIT_LIST_HEAD(&epi->pwqlist);
|
||||
epi->ep = ep;
|
||||
ep_set_ffd(&epi->ffd, tfile, fd);
|
||||
epi->event = *event;
|
||||
epi->nwait = 0;
|
||||
epi->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>nwait >= 0 && (pwq = kmem_cache_alloc(pwq_cache, GFP_KERNEL))) {
|
||||
init_waitqueue_func_entry(&pwq->wait, ep_poll_callback);
|
||||
pwq->whead = whead;
|
||||
pwq->base = epi;
|
||||
if (epi->event.events & EPOLLEXCLUSIVE)
|
||||
add_wait_queue_exclusive(whead, &pwq->wait);
|
||||
else
|
||||
add_wait_queue(whead, &pwq->wait);
|
||||
list_add_tail(&pwq->llink, &epi->pwqlist);
|
||||
epi->nwait++;
|
||||
} else {
|
||||
/* We have to signal that an error occurred */
|
||||
epi->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->ep;
|
||||
|
||||
```
|
||||
|
||||
接下来,进行一个加锁操作。
|
||||
|
||||
```
|
||||
spin_lock_irqsave(&ep->lock, flags);
|
||||
|
||||
```
|
||||
|
||||
下面对发生的事件进行过滤,为什么需要过滤呢?为了性能考虑,ep_insert向对应监控文件注册的是所有的事件,而实际用户侧订阅的事件未必和内核事件对应。比如,用户向内核订阅了一个套接字的可读事件,在某个时刻套接字的可写事件发生时,并不需要向用户空间传递这个事件。
|
||||
|
||||
```
|
||||
/*
|
||||
* Check the events coming with the callback. At this stage, not
|
||||
* every device reports the events in the "key" parameter of the
|
||||
* callback. We need to be able to handle both cases here, hence the
|
||||
* test for "key" != NULL before the event match test.
|
||||
*/
|
||||
if (key && !((unsigned long) key & epi->event.events))
|
||||
goto out_unlock;
|
||||
|
||||
```
|
||||
|
||||
接下来,判断是否需要把该事件传递给用户空间。
|
||||
|
||||
```
|
||||
if (unlikely(ep->ovflist != EP_UNACTIVE_PTR)) {
|
||||
if (epi->next == EP_UNACTIVE_PTR) {
|
||||
epi->next = ep->ovflist;
|
||||
ep->ovflist = epi;
|
||||
if (epi->ws) {
|
||||
/*
|
||||
* Activate ep->ws since epi->ws may get
|
||||
* deactivated at any time.
|
||||
*/
|
||||
__pm_stay_awake(ep->ws);
|
||||
}
|
||||
}
|
||||
goto out_unlock;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果需要,而且该事件对应的event_item不在eventpoll对应的已完成队列中,就把它放入该队列,以便将该事件传递给用户空间。
|
||||
|
||||
```
|
||||
/* If this file is already in the ready list we exit soon */
|
||||
if (!ep_is_linked(&epi->rdllink)) {
|
||||
list_add_tail(&epi->rdllink, &ep->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 ->poll()
|
||||
* wait list.
|
||||
*/
|
||||
if (waitqueue_active(&ep->wq)) {
|
||||
if ((epi->event.events & EPOLLEXCLUSIVE) &&
|
||||
!((unsigned long)key & POLLFREE)) {
|
||||
switch ((unsigned long)key & EPOLLINOUT_BITS) {
|
||||
case POLLIN:
|
||||
if (epi->event.events & POLLIN)
|
||||
ewake = 1;
|
||||
break;
|
||||
case POLLOUT:
|
||||
if (epi->event.events & POLLOUT)
|
||||
ewake = 1;
|
||||
break;
|
||||
case 0:
|
||||
ewake = 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
wake_up_locked(&ep->wq);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### 查找epoll实例
|
||||
|
||||
epoll_wait函数首先进行一系列的检查,例如传入的maxevents应该大于0。
|
||||
|
||||
```
|
||||
/* The maximum number of event must be greater than zero */
|
||||
if (maxevents <= 0 || maxevents > 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 "struct file *" 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 "private_data" contains
|
||||
* our own data structure.
|
||||
*/
|
||||
ep = f.file->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 > 0) {
|
||||
struct timespec64 end_time = ep_set_mstimeout(timeout);
|
||||
slack = select_estimate_accuracy(&end_time);
|
||||
to = &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(&ep->lock, flags);
|
||||
goto check_events;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
接下来尝试获得eventpoll上的锁:
|
||||
|
||||
```
|
||||
spin_lock_irqsave(&ep->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(&wait, current);
|
||||
__add_wait_queue_exclusive(&ep->wq, &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(&ep->lock, flags);
|
||||
|
||||
//通过调用schedule_hrtimeout_range,当前进程进入休眠,CPU时间被调度器调度给其他进程使用
|
||||
if (!schedule_hrtimeout_range(to, slack, HRTIMER_MODE_ABS))
|
||||
timed_out = 1;
|
||||
|
||||
spin_lock_irqsave(&ep->lock, flags);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果进程从休眠中返回,则将当前进程从eventpoll的等待队列中删除,并且设置当前进程为TASK_RUNNING状态。
|
||||
|
||||
```
|
||||
//从休眠中结束,将当前进程从wait队列中删除,设置状态为TASK_RUNNING,接下来进入check_events,来判断是否是有事件发生
|
||||
__remove_wait_queue(&ep->wq, &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 && eavail &&
|
||||
!(res = ep_send_events(ep, events, maxevents)) && !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, &pt);
|
||||
|
||||
/*
|
||||
* If the event mask intersect the caller-requested one,
|
||||
* deliver the event to userspace. Again, ep_scan_ready_list()
|
||||
* is holding "mtx", so no operations coming from userspace
|
||||
* can change the item.
|
||||
*/
|
||||
if (revents) {
|
||||
if (__put_user(revents, &uevent->events) ||
|
||||
__put_user(epi->event.data, &uevent->data)) {
|
||||
list_add(&epi->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->event.events & 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->rdllist besides us. The epoll_ctl()
|
||||
* callers are locked out by
|
||||
* ep_scan_ready_list() holding "mtx" and the
|
||||
* poll callback will queue them in ep->ovflist.
|
||||
*/
|
||||
list_add_tail(&epi->rdllink, &ep->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 < 0)
|
||||
goto out_nofds;
|
||||
|
||||
/* max_fds can increase, so grab it once to avoid race */
|
||||
rcu_read_lock();
|
||||
fdt = files_fdtable(current->files);
|
||||
max_fds = fdt->max_fds;
|
||||
rcu_read_unlock();
|
||||
if (n > 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 > sizeof(stack_fds) / 6) {
|
||||
/* Not enough space in on-stack array; must use kmalloc */
|
||||
ret = -ENOMEM;
|
||||
if (size > (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->in; outp = fds->out; exp = fds->ex;
|
||||
rinp = fds->res_in; routp = fds->res_out; rexp = fds->res_ex;
|
||||
|
||||
for (i = 0; i < 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(&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下可以享受高性能网络服务器带来的种种技术红利。
|
||||
154
极客时间专栏/网络编程实战/第二模块:提高篇/10 | TIME_WAIT:隐藏在细节下的魔鬼.md
Normal file
154
极客时间专栏/网络编程实战/第二模块:提高篇/10 | TIME_WAIT:隐藏在细节下的魔鬼.md
Normal 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停留持续时间是固定的,是最长分节生命期MSL(maximum segment lifetime)的两倍,一般称之为2MSL。和大多数BSD派生的系统一样,Linux系统里有一个硬编码的字段,名称为`TCP_TIMEWAIT_LEN`,其值为60秒。也就是说,**Linux系统停留在TIME_WAIT的时间为固定的60秒。**
|
||||
|
||||
```
|
||||
#define TCP_TIMEWAIT_LEN (60*HZ) /* how long to wait to destroy TIME- WAIT state, about 60 seconds */
|
||||
|
||||
```
|
||||
|
||||
过了这个时间之后,主机1就进入CLOSED状态。为什么是这个时间呢?你可以先想一想,稍后我会给出解答。
|
||||
|
||||
你一定要记住一点,**只有发起连接终止的一方会进入TIME_WAIT状态**。这一点面试的时候经常会被问到。
|
||||
|
||||
## TIME_WAIT的作用
|
||||
|
||||
你可能会问,为什么不直接进入CLOSED状态,而要停留在TIME_WAIT这个状态?
|
||||
|
||||
这要从两个方面来说。
|
||||
|
||||
首先,这样做是为了确保最后的ACK能让被动关闭方接收,从而帮助其正常关闭。
|
||||
|
||||
TCP在设计的时候,做了充分的容错性设计,比如,TCP假设报文会出错,需要重传。在这里,如果图中主机1的ACK报文没有传输成功,那么主机2就会重新发送FIN报文。
|
||||
|
||||
如果主机1没有维护TIME_WAIT状态,而直接进入CLOSED状态,它就失去了当前状态的上下文,只能回复一个RST操作,从而导致被动关闭方出现错误。
|
||||
|
||||
现在主机1知道自己处于TIME_WAIT的状态,就可以在接收到FIN报文之后,重新发出一个ACK报文,使得主机2可以进入正常的CLOSED状态。
|
||||
|
||||
第二个理由和连接“化身”和报文迷走有关系,为了让旧连接的重复分节在网络中自然消失。
|
||||
|
||||
我们知道,在网络中,经常会发生报文经过一段时间才能到达目的地的情况,产生的原因是多种多样的,如路由器重启,链路突然出现故障等。如果迷走报文到达时,发现TCP连接四元组(源IP,源端口,目的IP,目的端口)所代表的连接不复存在,那么很简单,这个报文自然丢弃。
|
||||
|
||||
我们考虑这样一个场景,在原连接中断后,又重新创建了一个原连接的“化身”,说是化身其实是因为这个连接和原先的连接四元组完全相同,如果迷失报文经过一段时间也到达,那么这个报文会被误认为是连接“化身”的一个TCP分节,这样就会对TCP通信产生影响。
|
||||
|
||||
<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连接至少消耗一个本地端口。要知道,端口资源也是有限的,一般可以开启的端口为32768~61000 ,也可以通过`net.ipv4.ip_local_port_range`指定,如果TIME_WAIT状态过多,会导致无法创建新连接。这个也是我们在一开始讲到的那个例子。
|
||||
|
||||
## 如何优化TIME_WAIT?
|
||||
|
||||
在高并发的情况下,如果我们想对TIME_WAIT做一些优化,来解决我们一开始提到的例子,该如何办呢?
|
||||
|
||||
### net.ipv4.tcp_max_tw_buckets
|
||||
|
||||
一个暴力的方法是通过sysctl命令,将系统值调小。这个值默认为18000,当系统中处于TIME_WAIT的连接一旦超过这个值时,系统就会将所有的TIME_WAIT连接状态重置,并且只打印出警告信息。这个方法过于暴力,而且治标不治本,带来的问题远比解决的问题多,不推荐使用。
|
||||
|
||||
### 调低TCP_TIMEWAIT_LEN,重新编译系统
|
||||
|
||||
这个方法是一个不错的方法,缺点是需要“一点”内核方面的知识,能够重新编译内核。我想这个不是大多数人能接受的方式。
|
||||
|
||||
### SO_LINGER的设置
|
||||
|
||||
英文单词“linger”的意思为停留,我们可以通过设置套接字选项,来设置调用close或者shutdown关闭连接时的行为。
|
||||
|
||||
```
|
||||
int setsockopt(int sockfd, int level, int optname, const void *optval,
|
||||
socklen_t optlen);
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
struct linger {
|
||||
int l_onoff; /* 0=off, nonzero=on */
|
||||
int l_linger; /* linger time, POSIX specifies units as seconds */
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
设置linger参数有几种可能:
|
||||
|
||||
- 如果`l_onoff`为0,那么关闭本选项。`l_linger`的值被忽略,这对应了默认行为,close或shutdown立即返回。如果在套接字发送缓冲区中有数据残留,系统会将试着把这些数据发送出去。
|
||||
- 如果`l_onoff`为非0, 且`l_linger`值也为0,那么调用close后,会立该发送一个RST标志给对端,该TCP连接将跳过四次挥手,也就跳过了TIME_WAIT状态,直接关闭。这种关闭的方式称为“强行关闭”。 在这种情况下,排队数据不会被发送,被动关闭方也不知道对端已经彻底断开。只有当被动关闭方正阻塞在`recv()`调用上时,接受到RST时,会立刻得到一个“connet reset by peer”的异常。
|
||||
|
||||
```
|
||||
struct linger so_linger;
|
||||
so_linger.l_onoff = 1;
|
||||
so_linger.l_linger = 0;
|
||||
setsockopt(s,SOL_SOCKET,SO_LINGER, &so_linger,sizeof(so_linger));
|
||||
|
||||
```
|
||||
|
||||
- 如果`l_onoff`为非0, 且`l_linger`的值也非0,那么调用close后,调用close的线程就将阻塞,直到数据被发送出去,或者设置的`l_linger`计时时间到。
|
||||
|
||||
第二种可能为跨越TIME_WAIT状态提供了一个可能,不过是一个非常危险的行为,不值得提倡。
|
||||
|
||||
### net.ipv4.tcp_tw_reuse:更安全的设置
|
||||
|
||||
那么Linux有没有提供更安全的选择呢?
|
||||
|
||||
当然有。这就是`net.ipv4.tcp_tw_reuse`选项。
|
||||
|
||||
Linux系统对于`net.ipv4.tcp_tw_reuse`的解释如下:
|
||||
|
||||
```
|
||||
Allow to reuse TIME-WAIT sockets for new connections when it is safe from protocol viewpoint. Default value is 0.It should not be changed without advice/request of technical experts.
|
||||
|
||||
```
|
||||
|
||||
这段话的大意是从协议角度理解如果是安全可控的,可以复用处于TIME_WAIT的套接字为新的连接所用。
|
||||
|
||||
那么什么是协议角度理解的安全可控呢?主要有两点:
|
||||
|
||||
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,欢迎你把这篇文章分享给你的朋友或者同事,一起交流学习一下。
|
||||
357
极客时间专栏/网络编程实战/第二模块:提高篇/11 | 优雅地关闭还是粗暴地关闭 ?.md
Normal file
357
极客时间专栏/网络编程实战/第二模块:提高篇/11 | 优雅地关闭还是粗暴地关闭 ?.md
Normal 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 "lib/common.h"
|
||||
# define MAXLINE 4096
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (argc != 2) {
|
||||
error(1, 0, "usage: graceclient <IPaddress>");
|
||||
}
|
||||
|
||||
int socket_fd;
|
||||
socket_fd = socket(AF_INET, SOCK_STREAM, 0);
|
||||
|
||||
struct sockaddr_in server_addr;
|
||||
bzero(&server_addr, sizeof(server_addr));
|
||||
server_addr.sin_family = AF_INET;
|
||||
server_addr.sin_port = htons(SERV_PORT);
|
||||
inet_pton(AF_INET, argv[1], &server_addr.sin_addr);
|
||||
|
||||
socklen_t server_len = sizeof(server_addr);
|
||||
int connect_rt = connect(socket_fd, (struct sockaddr *) &server_addr, server_len);
|
||||
if (connect_rt < 0) {
|
||||
error(1, errno, "connect failed ");
|
||||
}
|
||||
|
||||
char send_line[MAXLINE], recv_line[MAXLINE + 1];
|
||||
int n;
|
||||
|
||||
fd_set readmask;
|
||||
fd_set allreads;
|
||||
|
||||
FD_ZERO(&allreads);
|
||||
FD_SET(0, &allreads);
|
||||
FD_SET(socket_fd, &allreads);
|
||||
for (;;) {
|
||||
readmask = allreads;
|
||||
int rc = select(socket_fd + 1, &readmask, NULL, NULL, NULL);
|
||||
if (rc <= 0)
|
||||
error(1, errno, "select failed");
|
||||
if (FD_ISSET(socket_fd, &readmask)) {
|
||||
n = read(socket_fd, recv_line, MAXLINE);
|
||||
if (n < 0) {
|
||||
error(1, errno, "read error");
|
||||
} else if (n == 0) {
|
||||
error(1, 0, "server terminated \n");
|
||||
}
|
||||
recv_line[n] = 0;
|
||||
fputs(recv_line, stdout);
|
||||
fputs("\n", stdout);
|
||||
}
|
||||
if (FD_ISSET(0, &readmask)) {
|
||||
if (fgets(send_line, MAXLINE, stdin) != NULL) {
|
||||
if (strncmp(send_line, "shutdown", 8) == 0) {
|
||||
FD_CLR(0, &allreads);
|
||||
if (shutdown(socket_fd, 1)) {
|
||||
error(1, errno, "shutdown failed");
|
||||
}
|
||||
} else if (strncmp(send_line, "close", 5) == 0) {
|
||||
FD_CLR(0, &allreads);
|
||||
if (close(socket_fd)) {
|
||||
error(1, errno, "close failed");
|
||||
}
|
||||
sleep(6);
|
||||
exit(0);
|
||||
} else {
|
||||
int i = strlen(send_line);
|
||||
if (send_line[i - 1] == '\n') {
|
||||
send_line[i - 1] = 0;
|
||||
}
|
||||
|
||||
printf("now sending %s\n", send_line);
|
||||
size_t rt = write(socket_fd, send_line, strlen(send_line));
|
||||
if (rt < 0) {
|
||||
error(1, errno, "write failed ");
|
||||
}
|
||||
printf("send bytes: %zu \n", rt);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我对这个程序的细节展开解释一下:
|
||||
|
||||
第一部分是套接字的创建和select初始工作:
|
||||
|
||||
- 9-10行创建了一个TCP套接字;
|
||||
- 12-16行设置了连接的目标服务器IPv4地址,绑定到了指定的IP和端口;
|
||||
- 18-22行使用创建的套接字,向目标IPv4地址发起连接请求;
|
||||
- 30-32行为使用select做准备,初始化描述字集合,这部分我会在后面详细解释,这里就不再深入。
|
||||
|
||||
第二部分是程序的主体部分,从33-80行, 使用select多路复用观测在连接套接字和标准输入上的I/O事件,其中:
|
||||
|
||||
- 38-48行:当连接套接字上有数据可读,将数据读入到程序缓冲区中。40-41行,如果有异常则报错退出;42-43行如果读到服务器端发送的EOF则正常退出。
|
||||
- 49-77行:当标准输入上有数据可读,读入后进行判断。如果输入的是“shutdown”,则关闭标准输入的I/O事件感知,并调用shutdown函数关闭写方向;如果输入的是”close“,则调用close函数关闭连接;64-74行处理正常的输入,将回车符截掉,调用write函数,通过套接字将数据发送给服务器端。
|
||||
|
||||
服务器端程序稍微简单一点,连接建立之后,打印出接收的字节,并重新格式化后,发送给客户端。
|
||||
|
||||
服务器端程序有一点需要注意,那就是对SIGPIPE这个信号的处理。后面我会结合程序的结果展开说明。
|
||||
|
||||
```
|
||||
#include "lib/common.h"
|
||||
|
||||
static int count;
|
||||
|
||||
static void sig_int(int signo) {
|
||||
printf("\nreceived %d datagrams\n", count);
|
||||
exit(0);
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
int listenfd;
|
||||
listenfd = socket(AF_INET, SOCK_STREAM, 0);
|
||||
|
||||
struct sockaddr_in server_addr;
|
||||
bzero(&server_addr, sizeof(server_addr));
|
||||
server_addr.sin_family = AF_INET;
|
||||
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
|
||||
server_addr.sin_port = htons(SERV_PORT);
|
||||
|
||||
int rt1 = bind(listenfd, (struct sockaddr *) &server_addr, sizeof(server_addr));
|
||||
if (rt1 < 0) {
|
||||
error(1, errno, "bind failed ");
|
||||
}
|
||||
|
||||
int rt2 = listen(listenfd, LISTENQ);
|
||||
if (rt2 < 0) {
|
||||
error(1, errno, "listen failed ");
|
||||
}
|
||||
|
||||
signal(SIGINT, sig_int);
|
||||
signal(SIGPIPE, SIG_IGN);
|
||||
|
||||
int connfd;
|
||||
struct sockaddr_in client_addr;
|
||||
socklen_t client_len = sizeof(client_addr);
|
||||
|
||||
if ((connfd = accept(listenfd, (struct sockaddr *) &client_addr, &client_len)) < 0) {
|
||||
error(1, errno, "bind failed ");
|
||||
}
|
||||
|
||||
char message[MAXLINE];
|
||||
count = 0;
|
||||
|
||||
for (;;) {
|
||||
int n = read(connfd, message, MAXLINE);
|
||||
if (n < 0) {
|
||||
error(1, errno, "error read");
|
||||
} else if (n == 0) {
|
||||
error(1, 0, "client closed \n");
|
||||
}
|
||||
message[n] = 0;
|
||||
printf("received %d bytes: %s\n", n, message);
|
||||
count++;
|
||||
|
||||
char send_line[MAXLINE];
|
||||
sprintf(send_line, "Hi, %s", message);
|
||||
|
||||
sleep(5);
|
||||
|
||||
int write_nc = send(connfd, send_line, strlen(send_line), 0);
|
||||
printf("send bytes: %zu \n", write_nc);
|
||||
if (write_nc < 0) {
|
||||
error(1, errno, "error write");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
服务器端程序的细节也展开解释一下:
|
||||
|
||||
第一部分是套接字和连接创建过程:
|
||||
|
||||
- 11-12行创建了一个TCP套接字;
|
||||
- 14-18行设置了本地服务器IPv4地址,绑定到了ANY地址和指定的端口;
|
||||
- 20-40行使用创建的套接字,以此执行bind、listen和accept操作,完成连接建立。
|
||||
|
||||
第二部分是程序的主体,通过read函数获取客户端传送来的数据流,并回送给客户端:
|
||||
|
||||
- 51-52行显示收到的字符串,在56行对原字符串进行重新格式化,之后调用send函数将数据发送给客户端。注意,在发送之前,让服务器端程序休眠了5秒,以模拟服务器端处理的时间。
|
||||
|
||||
我们启动服务器,再启动客户端,依次在标准输入上输入data1、data2和close,观察一段时间后我们看到:
|
||||
|
||||
```
|
||||
$./graceclient 127.0.0.1
|
||||
data1
|
||||
now sending data1
|
||||
send bytes:5
|
||||
data2
|
||||
now sending data2
|
||||
send bytes:5
|
||||
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("\nreceived %d datagrams\n", count);
|
||||
exit(0);
|
||||
}
|
||||
signal(SIGINT, sig_pipe);
|
||||
|
||||
```
|
||||
|
||||
接下来,再次启动服务器,再启动客户端,依次在标准输入上输入data1、data2和shutdown函数,观察一段时间后我们看到:
|
||||
|
||||
```
|
||||
$./graceclient 127.0.0.1
|
||||
data1
|
||||
now sending data1
|
||||
send bytes:5
|
||||
data2
|
||||
now sending data2
|
||||
send bytes:5
|
||||
shutdown
|
||||
Hi, data1
|
||||
Hi,data2
|
||||
server terminated
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
$./graceserver
|
||||
received 5 bytes: data1
|
||||
send bytes: 9
|
||||
received 5 bytes: data2
|
||||
send bytes: 9
|
||||
client closed
|
||||
|
||||
```
|
||||
|
||||
和前面的结果不同,服务器端输出了data1、data2;客户端也输出了“Hi,data1”和“Hi,data2”,客户端和服务器端各自完成了自己的工作后,正常退出。
|
||||
|
||||
我们再看下客户端和服务器端交互的时序图。因为客户端调用shutdown函数只是关闭连接的一个方向,服务器端到客户端的这个方向还可以继续进行数据的发送和接收,所以“Hi,data1”和“Hi,data2”都可以正常传送;当服务器端读到EOF时,立即向客户端发送了FIN报文,客户端在read函数中感知了EOF,也进行了正常退出。
|
||||
|
||||
## 总结
|
||||
|
||||
在这一讲中,我们讲述了close函数关闭连接的方法,使用close函数关闭连接有两个需要明确的地方。
|
||||
|
||||
- close函数只是把套接字引用计数减1,未必会立即关闭连接;
|
||||
- close函数如果在套接字引用计数达到0时,立即终止读和写两个方向的数据传送。
|
||||
|
||||
基于这两个确定,在期望关闭连接其中一个方向时,应该使用shutdown函数。
|
||||
|
||||
## 思考题
|
||||
|
||||
和往常一样,给你留两道思考题。
|
||||
|
||||
第一道题,你可以看到在今天的服务器端程序中,直接调用`exit(0)`完成了FIN报文的发送,这是为什么呢?为什么不调用close函数或shutdown函数呢?
|
||||
|
||||
第二道题关于关于信号量处理,今天的程序中,使用的是`SIG_IGN`默认处理,你知道默认处理和自定义函数处理的区别吗?不妨查查资料,了解一下。
|
||||
|
||||
欢迎你在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,一起来交流。
|
||||
350
极客时间专栏/网络编程实战/第二模块:提高篇/12 | 连接无效:使用Keep-Alive还是应用心跳来检测?.md
Normal file
350
极客时间专栏/网络编程实战/第二模块:提高篇/12 | 连接无效:使用Keep-Alive还是应用心跳来检测?.md
Normal 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 "lib/common.h"
|
||||
#include "message_objecte.h"
|
||||
|
||||
#define MAXLINE 4096
|
||||
#define KEEP_ALIVE_TIME 10
|
||||
#define KEEP_ALIVE_INTERVAL 3
|
||||
#define KEEP_ALIVE_PROBETIMES 3
|
||||
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (argc != 2) {
|
||||
error(1, 0, "usage: tcpclient <IPaddress>");
|
||||
}
|
||||
|
||||
int socket_fd;
|
||||
socket_fd = socket(AF_INET, SOCK_STREAM, 0);
|
||||
|
||||
struct sockaddr_in server_addr;
|
||||
bzero(&server_addr, sizeof(server_addr));
|
||||
server_addr.sin_family = AF_INET;
|
||||
server_addr.sin_port = htons(SERV_PORT);
|
||||
inet_pton(AF_INET, argv[1], &server_addr.sin_addr);
|
||||
|
||||
socklen_t server_len = sizeof(server_addr);
|
||||
int connect_rt = connect(socket_fd, (struct sockaddr *) &server_addr, server_len);
|
||||
if (connect_rt < 0) {
|
||||
error(1, errno, "connect failed ");
|
||||
}
|
||||
|
||||
char recv_line[MAXLINE + 1];
|
||||
int n;
|
||||
|
||||
fd_set readmask;
|
||||
fd_set allreads;
|
||||
|
||||
struct timeval tv;
|
||||
int heartbeats = 0;
|
||||
|
||||
tv.tv_sec = KEEP_ALIVE_TIME;
|
||||
tv.tv_usec = 0;
|
||||
|
||||
messageObject messageObject;
|
||||
|
||||
FD_ZERO(&allreads);
|
||||
FD_SET(socket_fd, &allreads);
|
||||
for (;;) {
|
||||
readmask = allreads;
|
||||
int rc = select(socket_fd + 1, &readmask, NULL, NULL, &tv);
|
||||
if (rc < 0) {
|
||||
error(1, errno, "select failed");
|
||||
}
|
||||
if (rc == 0) {
|
||||
if (++heartbeats > KEEP_ALIVE_PROBETIMES) {
|
||||
error(1, 0, "connection dead\n");
|
||||
}
|
||||
printf("sending heartbeat #%d\n", heartbeats);
|
||||
messageObject.type = htonl(MSG_PING);
|
||||
rc = send(socket_fd, (char *) &messageObject, sizeof(messageObject), 0);
|
||||
if (rc < 0) {
|
||||
error(1, errno, "send failure");
|
||||
}
|
||||
tv.tv_sec = KEEP_ALIVE_INTERVAL;
|
||||
continue;
|
||||
}
|
||||
if (FD_ISSET(socket_fd, &readmask)) {
|
||||
n = read(socket_fd, recv_line, MAXLINE);
|
||||
if (n < 0) {
|
||||
error(1, errno, "read error");
|
||||
} else if (n == 0) {
|
||||
error(1, 0, "server terminated \n");
|
||||
}
|
||||
printf("received heartbeat, make heartbeats to 0 \n");
|
||||
heartbeats = 0;
|
||||
tv.tv_sec = KEEP_ALIVE_TIME;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个程序主要分成三大部分:
|
||||
|
||||
第一部分为套接字的创建和连接建立:
|
||||
|
||||
- 15-16行,创建了TCP套接字;
|
||||
- 18-22行,创建了IPv4目标地址,其实就是服务器端地址,注意这里使用的是传入参数作为服务器地址;
|
||||
- 24-28行,向服务器端发起连接。
|
||||
|
||||
第二部分为select定时器准备:
|
||||
|
||||
- 39-40行,设置了超时时间为KEEP_ALIVE_TIME,这相当于保活时间;
|
||||
- 44-45行,初始化select函数的套接字。
|
||||
|
||||
最重要的为第三部分,这一部分需要处理心跳报文:
|
||||
|
||||
- 48行调用select函数,感知I/O事件。这里的I/O事件,除了套接字上的读操作之外,还有在39-40行设置的超时事件。当KEEP_ALIVE_TIME这段时间到达之后,select函数会返回0,于是进入53-63行的处理;
|
||||
- 在53-63行,客户端已经在KEEP_ALIVE_TIME这段时间内没有收到任何对当前连接的反馈,于是发起PING消息,尝试问服务器端:“喂,你还活着吗?”这里我们通过传送一个类型为MSG_PING的消息对象来完成PING操作,之后我们会看到服务器端程序如何响应这个PING操作;
|
||||
- 第65-74行是客户端在接收到服务器端程序之后的处理。为了简单,这里就没有再进行报文格式的转换和分析。在实际的工作中,这里其实是需要对报文进行解析后处理的,只有是PONG类型的回应,我们才认为是PING探活的结果。这里认为既然收到服务器端的报文,那么连接就是正常的,所以会对探活计数器和探活时间都置零,等待下一次探活时间的来临。
|
||||
|
||||
### 服务器端程序设计
|
||||
|
||||
服务器端的程序接受一个参数,这个参数设置的比较大,可以模拟连接没有响应的情况。服务器端程序在接收到客户端发送来的各种消息后,进行处理,其中如果发现是PING类型的消息,在休眠一段时间后回复一个PONG消息,告诉客户端:“嗯,我还活着。”当然,如果这个休眠时间很长的话,那么客户端就无法快速知道服务器端是否存活,这是我们模拟连接无响应的一个手段而已,实际情况下,应该是系统崩溃,或者网络异常。
|
||||
|
||||
```
|
||||
#include "lib/common.h"
|
||||
#include "message_objecte.h"
|
||||
|
||||
static int count;
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (argc != 2) {
|
||||
error(1, 0, "usage: tcpsever <sleepingtime>");
|
||||
}
|
||||
|
||||
int sleepingTime = atoi(argv[1]);
|
||||
|
||||
int listenfd;
|
||||
listenfd = socket(AF_INET, SOCK_STREAM, 0);
|
||||
|
||||
struct sockaddr_in server_addr;
|
||||
bzero(&server_addr, sizeof(server_addr));
|
||||
server_addr.sin_family = AF_INET;
|
||||
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
|
||||
server_addr.sin_port = htons(SERV_PORT);
|
||||
|
||||
int rt1 = bind(listenfd, (struct sockaddr *) &server_addr, sizeof(server_addr));
|
||||
if (rt1 < 0) {
|
||||
error(1, errno, "bind failed ");
|
||||
}
|
||||
|
||||
int rt2 = listen(listenfd, LISTENQ);
|
||||
if (rt2 < 0) {
|
||||
error(1, errno, "listen failed ");
|
||||
}
|
||||
|
||||
int connfd;
|
||||
struct sockaddr_in client_addr;
|
||||
socklen_t client_len = sizeof(client_addr);
|
||||
|
||||
if ((connfd = accept(listenfd, (struct sockaddr *) &client_addr, &client_len)) < 0) {
|
||||
error(1, errno, "bind failed ");
|
||||
}
|
||||
|
||||
messageObject message;
|
||||
count = 0;
|
||||
|
||||
for (;;) {
|
||||
int n = read(connfd, (char *) &message, sizeof(messageObject));
|
||||
if (n < 0) {
|
||||
error(1, errno, "error read");
|
||||
} else if (n == 0) {
|
||||
error(1, 0, "client closed \n");
|
||||
}
|
||||
|
||||
printf("received %d bytes\n", n);
|
||||
count++;
|
||||
|
||||
switch (ntohl(message.type)) {
|
||||
case MSG_TYPE1 :
|
||||
printf("process MSG_TYPE1 \n");
|
||||
break;
|
||||
|
||||
case MSG_TYPE2 :
|
||||
printf("process MSG_TYPE2 \n");
|
||||
break;
|
||||
|
||||
case MSG_PING: {
|
||||
messageObject pong_message;
|
||||
pong_message.type = MSG_PONG;
|
||||
sleep(sleepingTime);
|
||||
ssize_t rc = send(connfd, (char *) &pong_message, sizeof(pong_message), 0);
|
||||
if (rc < 0)
|
||||
error(1, errno, "send failure");
|
||||
break;
|
||||
}
|
||||
|
||||
default :
|
||||
error(1, 0, "unknown message type (%d)\n", ntohl(message.type));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
服务器端程序主要分为两个部分。
|
||||
|
||||
第一部分为监听过程的建立,包括7-38行; 第13-14行先创建一个本地TCP监听套接字;16-20行绑定该套接字到本地端口和ANY地址上;第27-38行分别调用listen和accept完成被动套接字转换和监听。
|
||||
|
||||
第二部分为43行到77行,从建立的连接套接字上读取数据,解析报文,根据消息类型进行不同的处理。
|
||||
|
||||
- 55-57行为处理MSG_TYPE1的消息;
|
||||
- 59-61行为处理MSG_TYPE2的消息;
|
||||
- 重点是64-72行处理MSG_PING类型的消息。通过休眠来模拟响应是否及时,然后调用send函数发送一个PONG报文,向客户端表示“还活着”的意思;
|
||||
- 74行为异常处理,因为消息格式不认识,所以程序出错退出。
|
||||
|
||||
## 实验
|
||||
|
||||
基于上面的程序设计,让我们分别做两个不同的实验:
|
||||
|
||||
第一次实验,服务器端休眠时间为60秒。
|
||||
|
||||
我们看到,客户端在发送了三次心跳检测报文PING报文后,判断出连接无效,直接退出了。之所以造成这样的结果,是因为在这段时间内没有接收到来自服务器端的任何PONG报文。当然,实际工作的程序,可能需要不一样的处理,比如重新发起连接。
|
||||
|
||||
```
|
||||
$./pingclient 127.0.0.1
|
||||
sending heartbeat #1
|
||||
sending heartbeat #2
|
||||
sending heartbeat #3
|
||||
connection dead
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
$./pingserver 60
|
||||
received 1028 bytes
|
||||
received 1028 bytes
|
||||
|
||||
```
|
||||
|
||||
第二次实验,我们让服务器端休眠时间为5秒。
|
||||
|
||||
我们看到,由于这一次服务器端在心跳检测过程中,及时地进行了响应,客户端一直都会认为连接是正常的。
|
||||
|
||||
```
|
||||
$./pingclient 127.0.0.1
|
||||
sending heartbeat #1
|
||||
sending heartbeat #2
|
||||
received heartbeat, make heartbeats to 0
|
||||
received heartbeat, make heartbeats to 0
|
||||
sending heartbeat #1
|
||||
sending heartbeat #2
|
||||
received heartbeat, make heartbeats to 0
|
||||
received heartbeat, make heartbeats to 0
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
$./pingserver 5
|
||||
received 1028 bytes
|
||||
received 1028 bytes
|
||||
received 1028 bytes
|
||||
received 1028 bytes
|
||||
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
通过今天的文章,我们能看到虽然TCP没有提供系统的保活能力,让应用程序可以方便地感知连接的存活,但是,我们可以在应用程序里灵活地建立这种机制。一般来说,这种机制的建立依赖于系统定时器,以及恰当的应用层报文协议。比如,使用心跳包就是这样一种保持Keep Alive的机制。
|
||||
|
||||
## 思考题
|
||||
|
||||
和往常一样,我留两道思考题:
|
||||
|
||||
你可以看到今天的内容主要是针对TCP的探活,那么你觉得这样的方法是否同样适用于UDP呢?
|
||||
|
||||
第二道题是,有人说额外的探活报文占用了有限的带宽,对此你是怎么想的呢?而且,为什么需要多次探活才能决定一个TCP连接是否已经死亡呢?
|
||||
|
||||
欢迎你在评论区写下你的思考,我会和你一起交流。也欢迎把这篇文章分享给你的朋友或者同事,与他们一起讨论一下这两个问题吧。
|
||||
186
极客时间专栏/网络编程实战/第二模块:提高篇/13 | 小数据包应对之策:理解TCP协议中的动态数据传输.md
Normal file
186
极客时间专栏/网络编程实战/第二模块:提高篇/13 | 小数据包应对之策:理解TCP协议中的动态数据传输.md
Normal 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 *)&on, sizeof(on));
|
||||
|
||||
```
|
||||
|
||||
值得注意的是,除非我们对此有十足的把握,否则不要轻易改变默认的TCP Nagle算法。因为在现代操作系统中,针对Nagle算法和延时ACK的优化已经非常成熟了,有可能在禁用Nagle算法之后,性能问题反而更加严重。
|
||||
|
||||
## 将写操作合并
|
||||
|
||||
其实前面的例子里,如果我们能将一个请求一次性发送过去,而不是分开两部分独立发送,结果会好很多。所以,在写数据之前,将数据合并到缓冲区,批量发送出去,这是一个比较好的做法。不过,有时候数据会存储在两个不同的缓存中,对此,我们可以使用如下的方法来进行数据的读写操作,从而避免Nagle算法引发的副作用。
|
||||
|
||||
```
|
||||
ssize_t writev(int filedes, const struct iovec *iov, int iovcnt)
|
||||
ssize_t readv(int filedes, const struct iovec *iov, int iovcnt);
|
||||
|
||||
```
|
||||
|
||||
这两个函数的第二个参数都是指向某个iovec结构数组的一个指针,其中iovec结构定义如下:
|
||||
|
||||
```
|
||||
struct iovec {
|
||||
void *iov_base; /* starting address of buffer */
|
||||
size_t iov_len; /* size of buffer */
|
||||
};”
|
||||
|
||||
```
|
||||
|
||||
下面的程序展示了集中写的方式:
|
||||
|
||||
```
|
||||
int main(int argc, char **argv) {
|
||||
if (argc != 2) {
|
||||
error(1, 0, "usage: tcpclient <IPaddress>");
|
||||
}
|
||||
|
||||
int socket_fd;
|
||||
socket_fd = socket(AF_INET, SOCK_STREAM, 0);
|
||||
|
||||
struct sockaddr_in server_addr;
|
||||
bzero(&server_addr, sizeof(server_addr));
|
||||
server_addr.sin_family = AF_INET;
|
||||
server_addr.sin_port = htons(SERV_PORT);
|
||||
inet_pton(AF_INET, argv[1], &server_addr.sin_addr);
|
||||
|
||||
socklen_t server_len = sizeof(server_addr);
|
||||
int connect_rt = connect(socket_fd, (struct sockaddr *) &server_addr, server_len);
|
||||
if (connect_rt < 0) {
|
||||
error(1, errno, "connect failed ");
|
||||
}
|
||||
|
||||
char buf[128];
|
||||
struct iovec iov[2];
|
||||
|
||||
char *send_one = "hello,";
|
||||
iov[0].iov_base = send_one;
|
||||
iov[0].iov_len = strlen(send_one);
|
||||
iov[1].iov_base = buf;
|
||||
while (fgets(buf, sizeof(buf), stdin) != NULL) {
|
||||
iov[1].iov_len = strlen(buf);
|
||||
int n = htonl(iov[1].iov_len);
|
||||
if (writev(socket_fd, iov, 2) < 0)
|
||||
error(1, errno, "writev failure");
|
||||
}
|
||||
exit(0);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个程序的前半部分创建套接字,建立连接就不再赘述了。关键的是24-33行,使用了iovec数组,分别写入了两个不同的字符串,一个是“hello,”,另一个通过标准输入读入。
|
||||
|
||||
在启动该程序之前,我们需要启动服务器端程序,在客户端依次输入“world”和“network”:
|
||||
|
||||
```
|
||||
world
|
||||
network
|
||||
|
||||
```
|
||||
|
||||
接下来我们可以看到服务器端接收到了iovec组成的新的字符串。这里的原理其实就是在调用writev操作时,会自动把几个数组的输入合并成一个有序的字节流,然后发送给对端。
|
||||
|
||||
```
|
||||
received 12 bytes: hello,world
|
||||
|
||||
received 14 bytes: hello,network
|
||||
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
今天的内容我重点讲述了TCP流量控制的生产者-消费者模型,你需要记住以下几点:
|
||||
|
||||
- 发送窗口用来控制发送和接收端的流量;阻塞窗口用来控制多条连接公平使用的有限带宽。
|
||||
- 小数据包加剧了网络带宽的浪费,为了解决这个问题,引入了如Nagle算法、延时ACK等机制。
|
||||
- 在程序设计层面,不要多次频繁地发送小报文,如果有,可以使用writev批量发送。
|
||||
|
||||
## 思考题
|
||||
|
||||
和往常一样,留两道思考题:
|
||||
|
||||
针对最后呈现的writev函数,你可以查一查Linux下一次性最多允许数组的大小是多少?
|
||||
|
||||
另外TCP拥塞控制算法是一个非常重要的研究领域,请你查阅下最新的有关这方面的研究,看看有没有新的发现?
|
||||
|
||||
欢迎你在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,一起来交流。
|
||||
336
极客时间专栏/网络编程实战/第二模块:提高篇/14丨UDP也可以是“已连接”?.md
Normal file
336
极客时间专栏/网络编程实战/第二模块:提高篇/14丨UDP也可以是“已连接”?.md
Normal 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 "lib/common.h"
|
||||
# define MAXLINE 4096
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (argc != 2) {
|
||||
error(1, 0, "usage: udpclient1 <IPaddress>");
|
||||
}
|
||||
|
||||
int socket_fd;
|
||||
socket_fd = socket(AF_INET, SOCK_DGRAM, 0);
|
||||
|
||||
struct sockaddr_in server_addr;
|
||||
bzero(&server_addr, sizeof(server_addr));
|
||||
server_addr.sin_family = AF_INET;
|
||||
server_addr.sin_port = htons(SERV_PORT);
|
||||
inet_pton(AF_INET, argv[1], &server_addr.sin_addr);
|
||||
|
||||
socklen_t server_len = sizeof(server_addr);
|
||||
|
||||
if (connect(socket_fd, (struct sockaddr *) &server_addr, server_len)) {
|
||||
error(1, errno, "connect failed");
|
||||
}
|
||||
|
||||
struct sockaddr *reply_addr;
|
||||
reply_addr = malloc(server_len);
|
||||
|
||||
char send_line[MAXLINE], recv_line[MAXLINE + 1];
|
||||
socklen_t len;
|
||||
int n;
|
||||
|
||||
while (fgets(send_line, MAXLINE, stdin) != NULL) {
|
||||
int i = strlen(send_line);
|
||||
if (send_line[i - 1] == '\n') {
|
||||
send_line[i - 1] = 0;
|
||||
}
|
||||
|
||||
printf("now sending %s\n", send_line);
|
||||
size_t rt = sendto(socket_fd, send_line, strlen(send_line), 0, (struct sockaddr *) &server_addr, server_len);
|
||||
if (rt < 0) {
|
||||
error(1, errno, "sendto failed");
|
||||
}
|
||||
printf("send bytes: %zu \n", rt);
|
||||
|
||||
len = 0;
|
||||
recv_line[0] = 0;
|
||||
n = recvfrom(socket_fd, recv_line, MAXLINE, 0, reply_addr, &len);
|
||||
if (n < 0)
|
||||
error(1, errno, "recvfrom failed");
|
||||
recv_line[n] = 0;
|
||||
fputs(recv_line, stdout);
|
||||
fputs("\n", stdout);
|
||||
}
|
||||
|
||||
exit(0);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我对这个程序做一个简单的解释:
|
||||
|
||||
- 9-10行创建了一个UDP套接字;
|
||||
- 12-16行创建了一个IPv4地址,绑定到指定端口和IP;
|
||||
- **20-22行调用connect将UDP套接字和IPv4地址进行了“绑定”,这里connect函数的名称有点让人误解,其实可能更好的选择是叫做setpeername**;
|
||||
- 31-55行是程序的主体,读取标准输入字符串后,调用sendto发送给对端;之后调用recvfrom等待对端的响应,并把对端响应信息打印到标准输出。
|
||||
|
||||
在没有开启服务端的情况下,我们运行一下这个程序:
|
||||
|
||||
```
|
||||
$ ./udpconnectclient 127.0.0.1
|
||||
g1
|
||||
now sending g1
|
||||
send bytes: 2
|
||||
recvfrom failed: Connection refused (111)
|
||||
|
||||
```
|
||||
|
||||
看到这里你会不会觉得很奇怪?不是说好UDP是“无连接”的协议吗?不是说好UDP客户端只会阻塞在recvfrom这样的调用上吗?怎么这里冒出一个“Connection refused”的错误呢?
|
||||
|
||||
别着急,下面就跟着我的思路慢慢去解开这个谜团。
|
||||
|
||||
## UDP connect的作用
|
||||
|
||||
从前面的例子中,你会发现,我们可以对UDP套接字调用connect函数,但是和TCP connect调用引起TCP三次握手,建立TCP有效连接不同,UDP connect函数的调用,并不会引起和服务器目标端的网络交互,也就是说,并不会触发所谓的“握手”报文发送和应答。
|
||||
|
||||
那么对UDP套接字进行connect操作到底有什么意义呢?
|
||||
|
||||
其实上面的例子已经给出了答案,这主要是为了让应用程序能够接收“异步错误”的信息。
|
||||
|
||||
如果我们回想一下第6篇不调用connect操作的客户端程序,在服务器端不开启的情况下,客户端程序是不会报错的,程序只会阻塞在recvfrom上,等待返回(或者超时)。
|
||||
|
||||
在这里,我们通过对UDP套接字进行connect操作,将UDP套接字建立了“上下文”,该套接字和服务器端的地址和端口产生了联系,正是这种绑定关系给了操作系统内核必要的信息,能够将操作系统内核收到的信息和对应的套接字进行关联。
|
||||
|
||||
我们可以展开讨论一下。
|
||||
|
||||
事实上,当我们调用sendto或者send操作函数时,应用程序报文被发送,我们的应用程序返回,操作系统内核接管了该报文,之后操作系统开始尝试往对应的地址和端口发送,因为对应的地址和端口不可达,一个ICMP报文会返回给操作系统内核,该ICMP报文含有目的地址和端口等信息。
|
||||
|
||||
如果我们不进行connect操作,建立(UDP套接字——目的地址+端口)之间的映射关系,操作系统内核就没有办法把ICMP不可达的信息和UDP套接字进行关联,也就没有办法将ICMP信息通知给应用程序。
|
||||
|
||||
如果我们进行了connect操作,帮助操作系统内核从容建立了(UDP套接字——目的地址+端口)之间的映射关系,当收到一个ICMP不可达报文时,操作系统内核可以从映射表中找出是哪个UDP套接字拥有该目的地址和端口,别忘了套接字在操作系统内部是全局唯一的,当我们在该套接字上再次调用recvfrom或recv方法时,就可以收到操作系统内核返回的“Connection Refused”的信息。
|
||||
|
||||
## 收发函数
|
||||
|
||||
在对UDP进行connect之后,关于收发函数的使用,很多书籍是这样推荐的:
|
||||
|
||||
- 使用send或write函数来发送,如果使用sendto需要把相关的to地址信息置零;
|
||||
- 使用recv或read函数来接收,如果使用recvfrom需要把对应的from地址信息置零。
|
||||
|
||||
其实不同的UNIX实现对此表现出来的行为不尽相同。
|
||||
|
||||
在我的Linux 4.4.0环境中,使用sendto和recvfrom,系统会自动忽略to和from信息。在我的macOS 10.13中,确实需要遵守这样的规定,使用sendto或recvfrom会得到一些奇怪的结果,切回send和recv后正常。
|
||||
|
||||
考虑到兼容性,我们也推荐这些常规做法。所以在接下来的程序中,我会使用这样的做法来实现。
|
||||
|
||||
## 服务器端connect的例子
|
||||
|
||||
一般来说,服务器端不会主动发起connect操作,因为一旦如此,服务器端就只能响应一个客户端了。不过,有时候也不排除这样的情形,一旦一个客户端和服务器端发送UDP报文之后,该服务器端就要服务于这个唯一的客户端。
|
||||
|
||||
一个类似的服务器端程序如下:
|
||||
|
||||
```
|
||||
#include "lib/common.h"
|
||||
|
||||
static int count;
|
||||
|
||||
static void recvfrom_int(int signo) {
|
||||
printf("\nreceived %d datagrams\n", count);
|
||||
exit(0);
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
int socket_fd;
|
||||
socket_fd = socket(AF_INET, SOCK_DGRAM, 0);
|
||||
|
||||
struct sockaddr_in server_addr;
|
||||
bzero(&server_addr, sizeof(server_addr));
|
||||
server_addr.sin_family = AF_INET;
|
||||
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
|
||||
server_addr.sin_port = htons(SERV_PORT);
|
||||
|
||||
bind(socket_fd, (struct sockaddr *) &server_addr, sizeof(server_addr));
|
||||
|
||||
socklen_t client_len;
|
||||
char message[MAXLINE];
|
||||
message[0] = 0;
|
||||
count = 0;
|
||||
|
||||
signal(SIGINT, recvfrom_int);
|
||||
|
||||
struct sockaddr_in client_addr;
|
||||
client_len = sizeof(client_addr);
|
||||
|
||||
int n = recvfrom(socket_fd, message, MAXLINE, 0, (struct sockaddr *) &client_addr, &client_len);
|
||||
if (n < 0) {
|
||||
error(1, errno, "recvfrom failed");
|
||||
}
|
||||
message[n] = 0;
|
||||
printf("received %d bytes: %s\n", n, message);
|
||||
|
||||
if (connect(socket_fd, (struct sockaddr *) &client_addr, client_len)) {
|
||||
error(1, errno, "connect failed");
|
||||
}
|
||||
|
||||
while (strncmp(message, "goodbye", 7) != 0) {
|
||||
char send_line[MAXLINE];
|
||||
sprintf(send_line, "Hi, %s", message);
|
||||
|
||||
size_t rt = send(socket_fd, send_line, strlen(send_line), 0);
|
||||
if (rt < 0) {
|
||||
error(1, errno, "send failed ");
|
||||
}
|
||||
printf("send bytes: %zu \n", rt);
|
||||
|
||||
size_t rc = recv(socket_fd, message, MAXLINE, 0);
|
||||
if (rc < 0) {
|
||||
error(1, errno, "recv failed");
|
||||
}
|
||||
|
||||
count++;
|
||||
}
|
||||
|
||||
exit(0);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我对这个程序做下解释:
|
||||
|
||||
- 11-12行创建UDP套接字;
|
||||
- 14-18行创建IPv4地址,绑定到ANY和对应端口;
|
||||
- 20行绑定UDP套接字和IPv4地址;
|
||||
- 27行为该程序注册一个信号处理函数,以响应Ctrl+C信号量操作;
|
||||
- 32-37行调用recvfrom等待客户端报文到达,并将客户端信息保持到client_addr中;
|
||||
- **39-41行调用connect操作,将UDP套接字和客户端client_addr进行绑定**;
|
||||
- 43-59行是程序的主体,对接收的信息进行重新处理,加上”Hi“前缀后发送给客户端,并持续不断地从客户端接收报文,该过程一直持续,直到客户端发送“goodbye”报文为止。
|
||||
|
||||
注意这里所有收发函数都使用了send和recv。
|
||||
|
||||
接下来我们实现一个connect的客户端程序:
|
||||
|
||||
```
|
||||
#include "lib/common.h"
|
||||
# define MAXLINE 4096
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (argc != 2) {
|
||||
error(1, 0, "usage: udpclient3 <IPaddress>");
|
||||
}
|
||||
|
||||
int socket_fd;
|
||||
socket_fd = socket(AF_INET, SOCK_DGRAM, 0);
|
||||
|
||||
struct sockaddr_in server_addr;
|
||||
bzero(&server_addr, sizeof(server_addr));
|
||||
server_addr.sin_family = AF_INET;
|
||||
server_addr.sin_port = htons(SERV_PORT);
|
||||
inet_pton(AF_INET, argv[1], &server_addr.sin_addr);
|
||||
|
||||
socklen_t server_len = sizeof(server_addr);
|
||||
|
||||
if (connect(socket_fd, (struct sockaddr *) &server_addr, server_len)) {
|
||||
error(1, errno, "connect failed");
|
||||
}
|
||||
|
||||
char send_line[MAXLINE], recv_line[MAXLINE + 1];
|
||||
int n;
|
||||
|
||||
while (fgets(send_line, MAXLINE, stdin) != NULL) {
|
||||
int i = strlen(send_line);
|
||||
if (send_line[i - 1] == '\n') {
|
||||
send_line[i - 1] = 0;
|
||||
}
|
||||
|
||||
printf("now sending %s\n", send_line);
|
||||
size_t rt = send(socket_fd, send_line, strlen(send_line), 0);
|
||||
if (rt < 0) {
|
||||
error(1, errno, "send failed ");
|
||||
}
|
||||
printf("send bytes: %zu \n", rt);
|
||||
|
||||
recv_line[0] = 0;
|
||||
n = recv(socket_fd, recv_line, MAXLINE, 0);
|
||||
if (n < 0)
|
||||
error(1, errno, "recv failed");
|
||||
recv_line[n] = 0;
|
||||
fputs(recv_line, stdout);
|
||||
fputs("\n", stdout);
|
||||
}
|
||||
|
||||
exit(0);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我对这个客户端程序做一下解读:
|
||||
|
||||
- 9-10行创建了一个UDP套接字;
|
||||
- 12-16行创建了一个IPv4地址,绑定到指定端口和IP;
|
||||
- **20-22行调用connect将UDP套接字和IPv4地址进行了“绑定”**;
|
||||
- 27-46行是程序的主体,读取标准输入字符串后,调用send发送给对端;之后调用recv等待对端的响应,并把对端响应信息打印到标准输出。
|
||||
|
||||
注意这里所有收发函数也都使用了send和recv。
|
||||
|
||||
接下来,我们先启动服务器端程序,然后依次开启两个客户端,分别是客户端1、客户端2,并且让客户端1先发送UDP报文。
|
||||
|
||||
服务器端:
|
||||
|
||||
```
|
||||
$ ./udpconnectserver
|
||||
received 2 bytes: g1
|
||||
send bytes: 6
|
||||
|
||||
```
|
||||
|
||||
客户端1:
|
||||
|
||||
```
|
||||
./udpconnectclient2 127.0.0.1
|
||||
g1
|
||||
now sending g1
|
||||
send bytes: 2
|
||||
Hi, g1
|
||||
|
||||
```
|
||||
|
||||
客户端2:
|
||||
|
||||
```
|
||||
./udpconnectclient2 127.0.0.1
|
||||
g2
|
||||
now sending g2
|
||||
send bytes: 2
|
||||
recv failed: Connection refused (111)
|
||||
|
||||
```
|
||||
|
||||
我们看到,客户端1先发送报文,服务端随之通过connect和客户端1进行了“绑定”,这样,客户端2从操作系统内核得到了ICMP的错误,该错误在recv函数中返回,显示了“Connection refused”的错误信息。
|
||||
|
||||
## 性能考虑
|
||||
|
||||
一般来说,客户端通过connect绑定服务端的地址和端口,对UDP而言,可以有一定程度的性能提升。
|
||||
|
||||
这是为什么呢?
|
||||
|
||||
因为如果不使用connect方式,每次发送报文都会需要这样的过程:
|
||||
|
||||
连接套接字→发送报文→断开套接字→连接套接字→发送报文→断开套接字 →………
|
||||
|
||||
而如果使用connect方式,就会变成下面这样:
|
||||
|
||||
连接套接字→发送报文→发送报文→……→最后断开套接字
|
||||
|
||||
我们知道,连接套接字是需要一定开销的,比如需要查找路由表信息。所以,UDP客户端程序通过connect可以获得一定的性能提升。
|
||||
|
||||
## 总结
|
||||
|
||||
在今天的内容里,我对UDP套接字调用connect方法进行了深入的分析。之所以对UDP使用connect,绑定本地地址和端口,是为了让我们的程序可以快速获取异步错误信息的通知,同时也可以获得一定性能上的提升。
|
||||
|
||||
## 思考题
|
||||
|
||||
在本讲的最后,按照惯例,给你留两个思考题:
|
||||
|
||||
1. 可以对一个UDP 套接字进行多次connect操作吗? 你不妨动手试试,看看结果。
|
||||
1. 如果想使用多播或广播,我们应该怎么去使用connect呢?
|
||||
|
||||
欢迎你在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。
|
||||
250
极客时间专栏/网络编程实战/第二模块:提高篇/15 | 怎么老是出现“地址已经被使用”?.md
Normal file
250
极客时间专栏/网络编程实战/第二模块:提高篇/15 | 怎么老是出现“地址已经被使用”?.md
Normal 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("\nreceived %d datagrams\n", count);
|
||||
exit(0);
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
int listenfd;
|
||||
listenfd = socket(AF_INET, SOCK_STREAM, 0);
|
||||
|
||||
struct sockaddr_in server_addr;
|
||||
bzero(&server_addr, sizeof(server_addr));
|
||||
server_addr.sin_family = AF_INET;
|
||||
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
|
||||
server_addr.sin_port = htons(SERV_PORT);
|
||||
|
||||
int rt1 = bind(listenfd, (struct sockaddr *) &server_addr, sizeof(server_addr));
|
||||
if (rt1 < 0) {
|
||||
error(1, errno, "bind failed ");
|
||||
}
|
||||
|
||||
int rt2 = listen(listenfd, LISTENQ);
|
||||
if (rt2 < 0) {
|
||||
error(1, errno, "listen failed ");
|
||||
}
|
||||
|
||||
signal(SIGPIPE, SIG_IGN);
|
||||
|
||||
int connfd;
|
||||
struct sockaddr_in client_addr;
|
||||
socklen_t client_len = sizeof(client_addr);
|
||||
|
||||
if ((connfd = accept(listenfd, (struct sockaddr *) &client_addr, &client_len)) < 0) {
|
||||
error(1, errno, "bind failed ");
|
||||
}
|
||||
|
||||
char message[MAXLINE];
|
||||
count = 0;
|
||||
|
||||
for (;;) {
|
||||
int n = read(connfd, message, MAXLINE);
|
||||
if (n < 0) {
|
||||
error(1, errno, "error read");
|
||||
} else if (n == 0) {
|
||||
error(1, 0, "client closed \n");
|
||||
}
|
||||
message[n] = 0;
|
||||
printf("received %d bytes: %s\n", n, message);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个服务器端程序绑定到一个本地端口,使用的是通配地址ANY,当连接建立之后,从该连接中读取输入的字符流。
|
||||
|
||||
启动服务器,之后我们使用Telnet登录这个服务器,并在屏幕上输入一些字符,例如:network,good。
|
||||
|
||||
和我们期望的一样,服务器端打印出Telnet客户端的输入。在Telnet端关闭连接之后,服务器端接收到EOF,也顺利地关闭了连接。服务器端也可以很快重启,等待新的连接到来。
|
||||
|
||||
```
|
||||
$./addressused
|
||||
received 9 bytes: network
|
||||
received 6 bytes: good
|
||||
client closed
|
||||
$./addressused
|
||||
|
||||
```
|
||||
|
||||
接下来,我们改变一下连接的关闭顺序。和前面的过程一样,先启动服务器,再使用Telnet作为客户端登录到服务器,在屏幕上输入一些字符。注意接下来的不同,我不会在Telnet端关闭连接,而是直接使用Ctrl+C的方式在服务器端关闭连接。
|
||||
|
||||
```
|
||||
$telneet 127.0.0.1 9527
|
||||
network
|
||||
bad
|
||||
Connection closed by foreign host.
|
||||
|
||||
```
|
||||
|
||||
我们看到,连接已经被关闭,Telnet客户端也感知连接关闭并退出了。接下来,我们尝试重启服务器端程序。你会发现,这个时候服务端程序重启失败,报错信息为:**bind failed: Address already in use**。
|
||||
|
||||
```
|
||||
$./addressused
|
||||
received 9 bytes: network
|
||||
received 6 bytes: good
|
||||
client closed
|
||||
$./addressused
|
||||
bind faied: Address already in use(98)
|
||||
|
||||
```
|
||||
|
||||
## 复习TIME_WAIT
|
||||
|
||||
那么,这个错误到底是怎么发生的呢?
|
||||
|
||||
还记得第10篇文章里提到的TIME_WAIT么?当连接的一方主动关闭连接,在接收到对端的FIN报文之后,主动关闭连接的一方会在TIME_WAIT这个状态里停留一段时间,这个时间大约为2MSL。如果你对此有点淡忘,没有关系,我在下面放了一张图,希望会唤起你的记忆。
|
||||
|
||||
<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, &on, sizeof(on));
|
||||
|
||||
```
|
||||
|
||||
SO_REUSEADDR套接字选项,允许启动绑定在一个端口,即使之前存在一个和该端口一样的连接。前面的例子已经表明,在默认情况下,服务器端历经创建socket、bind和listen重启时,如果试图绑定到一个现有连接上的端口,bind操作会失败,但是如果我们在创建socket和bind之间,使用上面的代码片段设置SO_REUSEADDR套接字选项,情况就会不同。
|
||||
|
||||
下面我们对原来的服务器端代码进行升级,升级的部分主要在11-12行,在bind监听套接字之前,调用setsockopt方法,设置重用套接字选项:
|
||||
|
||||
```
|
||||
nt main(int argc, char **argv) {
|
||||
int listenfd;
|
||||
listenfd = socket(AF_INET, SOCK_STREAM, 0);
|
||||
|
||||
struct sockaddr_in server_addr;
|
||||
bzero(&server_addr, sizeof(server_addr));
|
||||
server_addr.sin_family = AF_INET;
|
||||
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
|
||||
server_addr.sin_port = htons(SERV_PORT);
|
||||
|
||||
int on = 1;
|
||||
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
|
||||
|
||||
int rt1 = bind(listenfd, (struct sockaddr *) &server_addr, sizeof(server_addr));
|
||||
if (rt1 < 0) {
|
||||
error(1, errno, "bind failed ");
|
||||
}
|
||||
|
||||
int rt2 = listen(listenfd, LISTENQ);
|
||||
if (rt2 < 0) {
|
||||
error(1, errno, "listen failed ");
|
||||
}
|
||||
|
||||
signal(SIGPIPE, SIG_IGN);
|
||||
|
||||
int connfd;
|
||||
struct sockaddr_in client_addr;
|
||||
socklen_t client_len = sizeof(client_addr);
|
||||
|
||||
if ((connfd = accept(listenfd, (struct sockaddr *) &client_addr, &client_len)) < 0) {
|
||||
error(1, errno, "bind failed ");
|
||||
}
|
||||
|
||||
char message[MAXLINE];
|
||||
count = 0;
|
||||
|
||||
for (;;) {
|
||||
int n = read(connfd, message, MAXLINE);
|
||||
if (n < 0) {
|
||||
error(1, errno, "error read");
|
||||
} else if (n == 0) {
|
||||
error(1, 0, "client closed \n");
|
||||
}
|
||||
message[n] = 0;
|
||||
printf("received %d bytes: %s\n", n, message);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
重新编译过后,重复上面那个例子,先启动服务器,再使用Telnet作为客户端登录到服务器,在屏幕上输入一些字符,使用Ctrl+C的方式在服务器端关闭连接。马上尝试重启服务器,这个时候我们发现,服务器正常启动,没有出现**Address already in use**的错误。这说明我们的修改已经起作用。
|
||||
|
||||
```
|
||||
$./addressused2
|
||||
received 9 bytes: network
|
||||
received 6 bytes: good
|
||||
client closed
|
||||
$./addressused2
|
||||
|
||||
```
|
||||
|
||||
SO_REUSEADDR套接字选项还有一个作用,那就是本机服务器如果有多个地址,可以在不同地址上使用相同的端口提供服务。
|
||||
|
||||
比如,一台服务器有192.168.1.101和10.10.2.102连个地址,我们可以在这台机器上启动三个不同的HTTP服务,第一个以本地通配地址ANY和端口80启动;第二个以192.168.101和端口80启动;第三个以10.10.2.102和端口80启动。
|
||||
|
||||
这样目的地址为192.168.101,目的端口为80的连接请求会被发往第二个服务;目的地址为10.10.2.102,目的端口为80的连接请求会被发往第三个服务;目的端口为80的所有其他连接请求被发往第一个服务。
|
||||
|
||||
我们必须给这三个服务设置SO_REUSEADDR套接字选项,否则第二个和第三个服务调用bind绑定到80端口时会出错。
|
||||
|
||||
## 最佳实践
|
||||
|
||||
这里的最佳实践可以总结成一句话: 服务器端程序,都应该设置SO_REUSEADDR套接字选项,以便服务端程序可以在极短时间内复用同一个端口启动。
|
||||
|
||||
有些人可能觉得这不是安全的。其实,单独重用一个套接字不会有任何问题。我在前面已经讲过,TCP连接是通过四元组唯一区分的,只要客户端不使用相同的源端口,连接服务器是没有问题的,即使使用了相同的端口,根据序列号或者时间戳,也是可以区分出新旧连接的。
|
||||
|
||||
而且,TCP的机制绝对不允许在相同的地址和端口上绑定不同的服务器,即使我们设置SO_REUSEADDR套接字选项,也不可能在ANY通配符地址下和端口9527上重复启动两个服务器实例。如果我们启动第二个服务器实例,不出所料会得到**Address already in use**的报错,即使当前还没有任何一条有效TCP连接产生。
|
||||
|
||||
比如下面就是第二次运行服务器端程序的报错信息:
|
||||
|
||||
```
|
||||
$./addressused2
|
||||
bind faied: Address already in use(98)
|
||||
|
||||
```
|
||||
|
||||
你可能还记得[第10讲](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函数之前对监听字进行设置,想一想,为什么不是对已连接的套接字进行设置呢?
|
||||
|
||||
欢迎你在评论区写下你的思考,我会和你一起讨论交流,也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。
|
||||
391
极客时间专栏/网络编程实战/第二模块:提高篇/16 | 如何理解TCP的“流”?.md
Normal file
391
极客时间专栏/网络编程实战/第二模块:提高篇/16 | 如何理解TCP的“流”?.md
Normal 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 ----> 接收端回应:Hi, network
|
||||
|
||||
发送端:program -----> 接收端回应: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代表的就是network,h代表的是host,s表示的是short,l表示的是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, "usage: tcpclient <IPaddress>");
|
||||
}
|
||||
|
||||
int socket_fd;
|
||||
socket_fd = socket(AF_INET, SOCK_STREAM, 0);
|
||||
|
||||
struct sockaddr_in server_addr;
|
||||
bzero(&server_addr, sizeof(server_addr));
|
||||
server_addr.sin_family = AF_INET;
|
||||
server_addr.sin_port = htons(SERV_PORT);
|
||||
inet_pton(AF_INET, argv[1], &server_addr.sin_addr);
|
||||
|
||||
socklen_t server_len = sizeof(server_addr);
|
||||
int connect_rt = connect(socket_fd, (struct sockaddr *) &server_addr, server_len);
|
||||
if (connect_rt < 0) {
|
||||
error(1, errno, "connect failed ");
|
||||
}
|
||||
|
||||
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 *) &message, sizeof(message.message_length) + sizeof(message.message_type) + n, 0) <
|
||||
0)
|
||||
error(1, errno, "send failure");
|
||||
|
||||
}
|
||||
exit(0);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
程序的1-20行是常规的创建套接字和地址,建立连接的过程。我们重点往下看,21-25行就是图示的报文格式转化为结构体,29-37行从标准输入读入数据,分别对消息长度、类型进行了初始化,注意这里使用了htonl函数将字节大小转化为了网络字节顺序,这一点很重要。最后我们看到23行实际发送的字节流大小为消息长度4字节,加上消息类型4字节,以及标准输入的字符串大小。
|
||||
|
||||
### 解析报文:程序
|
||||
|
||||
下面给出的是服务器端的程序,和客户端不一样的是,服务器端需要对报文进行解析。
|
||||
|
||||
```
|
||||
static int count;
|
||||
|
||||
static void sig_int(int signo) {
|
||||
printf("\nreceived %d datagrams\n", count);
|
||||
exit(0);
|
||||
}
|
||||
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
int listenfd;
|
||||
listenfd = socket(AF_INET, SOCK_STREAM, 0);
|
||||
|
||||
struct sockaddr_in server_addr;
|
||||
bzero(&server_addr, sizeof(server_addr));
|
||||
server_addr.sin_family = AF_INET;
|
||||
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
|
||||
server_addr.sin_port = htons(SERV_PORT);
|
||||
|
||||
int on = 1;
|
||||
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
|
||||
|
||||
int rt1 = bind(listenfd, (struct sockaddr *) &server_addr, sizeof(server_addr));
|
||||
if (rt1 < 0) {
|
||||
error(1, errno, "bind failed ");
|
||||
}
|
||||
|
||||
int rt2 = listen(listenfd, LISTENQ);
|
||||
if (rt2 < 0) {
|
||||
error(1, errno, "listen failed ");
|
||||
}
|
||||
|
||||
signal(SIGPIPE, SIG_IGN);
|
||||
|
||||
int connfd;
|
||||
struct sockaddr_in client_addr;
|
||||
socklen_t client_len = sizeof(client_addr);
|
||||
|
||||
if ((connfd = accept(listenfd, (struct sockaddr *) &client_addr, &client_len)) < 0) {
|
||||
error(1, errno, "bind failed ");
|
||||
}
|
||||
|
||||
char buf[128];
|
||||
count = 0;
|
||||
|
||||
while (1) {
|
||||
int n = read_message(connfd, buf, sizeof(buf));
|
||||
if (n < 0) {
|
||||
error(1, errno, "error read message");
|
||||
} else if (n == 0) {
|
||||
error(1, 0, "client closed \n");
|
||||
}
|
||||
buf[n] = 0;
|
||||
printf("received %d bytes: %s\n", 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 > 0) {
|
||||
nread = read(fd, ptr, count);
|
||||
|
||||
if (nread < 0) {
|
||||
if (errno == EINTR)
|
||||
continue;
|
||||
else
|
||||
return (-1);
|
||||
} else if (nread == 0)
|
||||
break; /* EOF */
|
||||
|
||||
count -= nread;
|
||||
ptr += nread;
|
||||
}
|
||||
return (length - count); /* return >= 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 *) &msg_length, sizeof(u_int32_t));
|
||||
if (rc != sizeof(u_int32_t))
|
||||
return rc < 0 ? -1 : 0;
|
||||
msg_length = ntohl(msg_length);
|
||||
|
||||
rc = readn(fd, (char *) &msg_type, sizeof(msg_type));
|
||||
if (rc != sizeof(u_int32_t))
|
||||
return rc < 0 ? -1 : 0;
|
||||
|
||||
if (msg_length > length) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
rc = readn(fd, buffer, msg_length);
|
||||
if (rc != msg_length)
|
||||
return rc < 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 < size - 1) && (c != '\n')) {
|
||||
n = recv(fd, &c, 1, 0);
|
||||
if (n > 0) {
|
||||
if (c == '\r') {
|
||||
n = recv(fd, &c, 1, MSG_PEEK);
|
||||
if ((n > 0) && (c == '\n'))
|
||||
recv(fd, &c, 1, 0);
|
||||
else
|
||||
c = '\n';
|
||||
}
|
||||
buf[i] = c;
|
||||
i++;
|
||||
} else
|
||||
c = '\n';
|
||||
}
|
||||
buf[i] = '\0';
|
||||
|
||||
return (i);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
和我们预想的不太一样,TCP数据流特性决定了字节流本身是没有边界的,一般我们通过显式编码报文长度的方式,以及选取特殊字符区分报文边界的方式来进行报文格式的设计。而对报文解析的工作就是要在知道报文格式的情况下,有效地对报文信息进行还原。
|
||||
|
||||
## 思考题
|
||||
|
||||
和往常一样,这里给你留两道思考题,供你消化今天的内容。
|
||||
|
||||
第一道题关于HTTP的报文格式,我们看到,既要处理只有回车的情景,也要处理同时有回车和换行的情景,你知道造成这种情况的原因是什么吗?
|
||||
|
||||
第二道题是,我们这里讲到的报文格式,和TCP分组的报文格式,有什么区别和联系吗?
|
||||
|
||||
欢迎你在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,与他们一起交流一下这两个问题吧。
|
||||
275
极客时间专栏/网络编程实战/第二模块:提高篇/17 | TCP并不总是“可靠”的?.md
Normal file
275
极客时间专栏/网络编程实战/第二模块:提高篇/17 | TCP并不总是“可靠”的?.md
Normal 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 < 0) {
|
||||
error(1, errno, "error read");
|
||||
} else if (n == 0) {
|
||||
error(1, 0, "client closed \n");
|
||||
}
|
||||
|
||||
sleep(5);
|
||||
|
||||
int write_nc = send(connfd, buf, n, 0);
|
||||
printf("send bytes: %zu \n", write_nc);
|
||||
if (write_nc < 0) {
|
||||
error(1, errno, "error write");
|
||||
}
|
||||
}
|
||||
|
||||
exit(0);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
服务端程序是一个简单的应答程序,在收到数据流之后回显给客户端,在此之前,休眠5秒,以便完成后面的实验验证。
|
||||
|
||||
客户端程序从标准输入读入,将读入的字符串传输给服务器端:
|
||||
|
||||
```
|
||||
//客户端程序
|
||||
int main(int argc, char **argv) {
|
||||
if (argc != 2) {
|
||||
error(1, 0, "usage: reliable_client01 <IPaddress>");
|
||||
}
|
||||
|
||||
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 < 0)
|
||||
error(1, errno, "write failed");
|
||||
rc = read(socket_fd, buf, sizeof(buf));
|
||||
if (rc < 0)
|
||||
error(1, errno, "read failed");
|
||||
else if (rc == 0)
|
||||
error(1, 0, "peer connection closed\n");
|
||||
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产生RST,read调用感知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 < 0) {
|
||||
error(1, errno, "error read");
|
||||
} else if (n == 0) {
|
||||
error(1, 0, "client closed \n");
|
||||
}
|
||||
|
||||
time++;
|
||||
fprintf(stdout, "1K read for %d \n", time);
|
||||
usleep(1000);
|
||||
}
|
||||
|
||||
exit(0);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
服务器端每次读取1K数据后休眠1秒,以模拟处理数据的过程。
|
||||
|
||||
客户端程序在第8行注册了SIGPIPE的信号处理程序,在第14-22行客户端程序一直循环发送数据流。
|
||||
|
||||
```
|
||||
int main(int argc, char **argv) {
|
||||
if (argc != 2) {
|
||||
error(1, 0, "usage: reliable_client02 <IPaddress>");
|
||||
}
|
||||
|
||||
int socket_fd = tcp_client(argv[1], SERV_PORT);
|
||||
|
||||
signal(SIGPIPE, SIG_IGN);
|
||||
|
||||
char *msg = "network programming";
|
||||
ssize_t n_written;
|
||||
|
||||
int count = 10000000;
|
||||
while (count > 0) {
|
||||
n_written = send(socket_fd, msg, strlen(msg), 0);
|
||||
fprintf(stdout, "send into buffer %ld \n", n_written);
|
||||
if (n_written <= 0) {
|
||||
error(1, errno, "send error");
|
||||
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系统中重新模拟一下今天文章里的实验,看看运行结果是否和我的一样。欢迎你把内核版本和结果贴在评论里。
|
||||
|
||||
第二道题是,如果服务器主机正常关闭,已连接的程序会发生什么呢?
|
||||
|
||||
你不妨思考一下这两道题,欢迎你在评论区写下你的模拟结果和思考,我会和你一起交流,也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。
|
||||
335
极客时间专栏/网络编程实战/第二模块:提高篇/18 | 防人之心不可无:检查数据的有效性.md
Normal file
335
极客时间专栏/网络编程实战/第二模块:提高篇/18 | 防人之心不可无:检查数据的有效性.md
Normal 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, "error read message");
|
||||
} else if (nBytes == 0) {
|
||||
error(1, 0, "client closed \n");
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
你可以看到这一个程序中的第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 *) &tv, sizeof tv);
|
||||
|
||||
while (1) {
|
||||
int nBytes = recv(connfd, buffer, sizeof(buffer), 0);
|
||||
if (nBytes == -1) {
|
||||
if (errno == EAGAIN || errno == EWOULDBLOCK) {
|
||||
printf("read timeout\n");
|
||||
onClientTimeout(connfd);
|
||||
} else {
|
||||
error(1, errno, "error read message");
|
||||
}
|
||||
} else if (nBytes == 0) {
|
||||
error(1, 0, "client closed \n");
|
||||
}
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个代码片段在第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(&allreads);
|
||||
FD_SET(socket_fd, &allreads);
|
||||
for (;;) {
|
||||
readmask = allreads;
|
||||
int rc = select(socket_fd + 1, &readmask, NULL, NULL, &tv);
|
||||
if (rc < 0) {
|
||||
error(1, errno, "select failed");
|
||||
}
|
||||
if (rc == 0) {
|
||||
printf("read timeout\n");
|
||||
onClientTimeout(socket_fd);
|
||||
}
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这段代码使用了select多路复用技术来对套接字进行I/O事件的轮询,程序的13行是到达超时后的处理逻辑,调用`onClientTimeout`函数来进行超时后的处理。
|
||||
|
||||
## 缓冲区处理
|
||||
|
||||
一个设计良好的网络程序,应该可以在随机输入的情况下表现稳定。不仅是这样,随着互联网的发展,网络安全也愈发重要,我们编写的网络程序能不能在黑客的刻意攻击之下表现稳定,也是一个重要考量因素。
|
||||
|
||||
很多黑客程序,会针对性地构建出一定格式的网络协议包,导致网络程序产生诸如缓冲区溢出、指针异常的后果,影响程序的服务能力,严重的甚至可以夺取服务器端的控制权,随心所欲地进行破坏活动,比如著名的SQL注入,就是通过针对性地构造出SQL语句,完成对数据库敏感信息的窃取。
|
||||
|
||||
所以,在网络程序的编写过程中,我们需要时时刻刻提醒自己面对的是各种复杂异常的场景,甚至是别有用心的攻击者,保持“防人之心不可无”的警惕。
|
||||
|
||||
那么程序都有可能出现哪几种漏洞呢?
|
||||
|
||||
### 第一个例子
|
||||
|
||||
```
|
||||
char Response[] = "COMMAND OK";
|
||||
char buffer[128];
|
||||
|
||||
while (1) {
|
||||
int nBytes = recv(connfd, buffer, sizeof(buffer), 0);
|
||||
if (nBytes == -1) {
|
||||
error(1, errno, "error read message");
|
||||
} else if (nBytes == 0) {
|
||||
error(1, 0, "client closed \n");
|
||||
}
|
||||
|
||||
buffer[nBytes] = '\0';
|
||||
if (strcmp(buffer, "quit") == 0) {
|
||||
printf("client quit\n");
|
||||
send(socket, Response, sizeof(Response), 0);
|
||||
}
|
||||
|
||||
printf("received %d bytes: %s\n", 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 *) &msg_length, sizeof(u_int32_t));
|
||||
if (rc != sizeof(u_int32_t))
|
||||
return rc < 0 ? -1 : 0;
|
||||
msg_length = ntohl(msg_length);
|
||||
|
||||
rc = readn(fd, (char *) &msg_type, sizeof(msg_type));
|
||||
if (rc != sizeof(u_int32_t))
|
||||
return rc < 0 ? -1 : 0;
|
||||
|
||||
if (msg_length > length) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Retrieve the record itself */
|
||||
rc = readn(fd, buffer, msg_length);
|
||||
if (rc != msg_length)
|
||||
return rc < 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] = "just for fun\0";
|
||||
strncpy(message.data, buf, strlen(buf));
|
||||
if (send(socket_fd, (char *) &message,
|
||||
sizeof(message.message_length) + sizeof(message.message_type) + strlen(message.data), 0) < 0)
|
||||
error(1, errno, "send failure");
|
||||
|
||||
```
|
||||
|
||||
就是这样一段发送端“不小心”构造的一个程序,消息的长度“不小心”被设置为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 > 0 && recv(fd, &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-- > 0) {
|
||||
if (nleft <= 0) {
|
||||
int nread = recv(fd, read_buffer, sizeof(read_buffer), 0);
|
||||
if (nread < 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> 0) {
|
||||
if (nleft <= 0) {
|
||||
int nread = recv(fd, read_buffer, sizeof(read_buffer), 0);
|
||||
if (nread < 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函数来分配缓冲区?
|
||||
|
||||
欢迎你在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。
|
||||
115
极客时间专栏/网络编程实战/第二模块:提高篇/19丨提高篇答疑:如何理解TCP四次挥手?.md
Normal file
115
极客时间专栏/网络编程实战/第二模块:提高篇/19丨提高篇答疑:如何理解TCP四次挥手?.md
Normal 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&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数据报能够在因特网中存活的最长时间。其实它的实现不是靠计时器来完成的,在每个数据报里都包含有一个被称为TTL(time 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 *) &client_addr, &client_len);
|
||||
message[n] = 0;
|
||||
printf("received %d bytes: %s\n", n, message);
|
||||
|
||||
char send_line[MAXLINE];
|
||||
sprintf(send_line, "Hi, %s", message);
|
||||
|
||||
//服务器端程序调用send函数,把客户端的地址和端口信息告诉了内核
|
||||
sendto(socket_fd, send_line, strlen(send_line), 0, (struct sockaddr *) &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="">
|
||||
|
||||
## 总结
|
||||
|
||||
以上就是提高篇中一些同学的疑问。我们常说,学问学问,有学才有问。我希望通过今天的答疑可以让你加深对文章的理解,为后面的模块做准备。
|
||||
|
||||
这篇文章之后,我们就将进入到专栏中最重要的部分,也就是性能篇和实战篇了,在性能篇和实战篇里,我们将会使用到之前学到的知识,逐渐打造一个高性能的网络程序框架,你,准备好了吗?
|
||||
|
||||
如果你觉得今天的答疑内容对你有所帮助,欢迎把它转发给你的朋友或者同事,一起交流一下。
|
||||
342
极客时间专栏/网络编程实战/第四模块:实战篇/32 | 自己动手写高性能HTTP服务器(一):设计和思路.md
Normal file
342
极客时间专栏/网络编程实战/第四模块:实战篇/32 | 自己动手写高性能HTTP服务器(一):设计和思路.md
Normal 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的线程ID,mutex和con是用来进行线程同步的。
|
||||
|
||||
socketPair是父线程用来通知子线程有新的事件需要处理。pending_head和pending_tail是保留在子线程内的需要处理的新事件。
|
||||
|
||||
```
|
||||
struct event_loop {
|
||||
int quit;
|
||||
const struct event_dispatcher *eventDispatcher;
|
||||
|
||||
/** 对应的event_dispatcher的数据. */
|
||||
void *event_dispatcher_data;
|
||||
struct channel_map *channelMap;
|
||||
|
||||
int is_handle_pending;
|
||||
struct channel_element *pending_head;
|
||||
struct channel_element *pending_tail;
|
||||
|
||||
pthread_t owner_thread_id;
|
||||
pthread_mutex_t mutex;
|
||||
pthread_cond_t cond;
|
||||
int socketPair[2];
|
||||
char *thread_name;
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
下面我们看一下event_loop最主要的方法event_loop_run方法,前面提到过,event_loop就是一个无限while循环,不断地在分发事件。
|
||||
|
||||
```
|
||||
/**
|
||||
*
|
||||
* 1.参数验证
|
||||
* 2.调用dispatcher来进行事件分发,分发完回调事件处理函数
|
||||
*/
|
||||
int event_loop_run(struct event_loop *eventLoop) {
|
||||
assert(eventLoop != NULL);
|
||||
|
||||
struct event_dispatcher *dispatcher = eventLoop->eventDispatcher;
|
||||
|
||||
if (eventLoop->owner_thread_id != pthread_self()) {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
yolanda_msgx("event loop run, %s", eventLoop->thread_name);
|
||||
struct timeval timeval;
|
||||
timeval.tv_sec = 1;
|
||||
|
||||
while (!eventLoop->quit) {
|
||||
//block here to wait I/O event, and get active channels
|
||||
dispatcher->dispatch(eventLoop, &timeval);
|
||||
|
||||
//handle the pending channel
|
||||
event_loop_handle_pending_channel(eventLoop);
|
||||
}
|
||||
|
||||
yolanda_msgx("event loop end, %s", eventLoop->thread_name);
|
||||
return 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
代码很明显地反映了这一点,这里我们在event_loop不退出的情况下,一直在循环,循环体中调用了dispatcher对象的dispatch方法来等待事件的发生。
|
||||
|
||||
### event_dispacher分析
|
||||
|
||||
为了实现不同的事件分发机制,这里把poll、epoll等抽象成了一个event_dispatcher结构。event_dispatcher的具体实现有poll_dispatcher和epoll_dispatcher两种,实现的方法和性能篇[21](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->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->channelMap;
|
||||
yolanda_msgx("activate channel fd == %d, revents=%d, %s", fd, revents, eventLoop->thread_name);
|
||||
|
||||
if (fd < 0)
|
||||
return 0;
|
||||
|
||||
if (fd >= map->nentries)return (-1);
|
||||
|
||||
struct channel *channel = map->entries[fd];
|
||||
assert(fd == channel->fd);
|
||||
|
||||
if (revents & (EVENT_READ)) {
|
||||
if (channel->eventReadCallback) channel->eventReadCallback(channel->data);
|
||||
}
|
||||
if (revents & (EVENT_WRITE)) {
|
||||
if (channel->eventWriteCallback) channel->eventWriteCallback(channel->data);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### 增加、删除、修改channel event
|
||||
|
||||
那么如何增加新的channel event事件呢?下面这几个函数是用来增加、删除和修改channel event事件的。
|
||||
|
||||
```
|
||||
int event_loop_add_channel_event(struct event_loop *eventLoop, int fd, struct channel *channel1);
|
||||
|
||||
int event_loop_remove_channel_event(struct event_loop *eventLoop, int fd, struct channel *channel1);
|
||||
|
||||
int event_loop_update_channel_event(struct event_loop *eventLoop, int fd, struct channel *channel1);
|
||||
|
||||
```
|
||||
|
||||
前面三个函数提供了入口能力,而真正的实现则落在这三个函数上:
|
||||
|
||||
```
|
||||
int event_loop_handle_pending_add(struct event_loop *eventLoop, int fd, struct channel *channel);
|
||||
|
||||
int event_loop_handle_pending_remove(struct event_loop *eventLoop, int fd, struct channel *channel);
|
||||
|
||||
int event_loop_handle_pending_update(struct event_loop *eventLoop, int fd, struct channel *channel);
|
||||
|
||||
```
|
||||
|
||||
我们看一下其中的一个实现,event_loop_handle_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("add channel fd == %d, %s", fd, eventLoop->thread_name);
|
||||
struct channel_map *map = eventLoop->channelMap;
|
||||
|
||||
if (fd < 0)
|
||||
return 0;
|
||||
|
||||
if (fd >= map->nentries) {
|
||||
if (map_make_space(map, fd, sizeof(struct channel *)) == -1)
|
||||
return (-1);
|
||||
}
|
||||
|
||||
//第一次创建,增加
|
||||
if ((map)->entries[fd] == NULL) {
|
||||
map->entries[fd] = channel;
|
||||
//add channel
|
||||
struct event_dispatcher *eventDispatcher = eventLoop->eventDispatcher;
|
||||
eventDispatcher->add(eventLoop, channel);
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
在这一讲里,我们介绍了高性能网络编程框架的主要设计思路和基本数据结构,以及反应堆设计相关的具体做法。在接下来的章节中,我们将继续编写高性能网络编程框架的线程模型以及读写Buffer部分。
|
||||
|
||||
## 思考题
|
||||
|
||||
和往常一样,给你留两道思考题:
|
||||
|
||||
第一道,如果你有兴趣,不妨实现一个select_dispatcher对象,用select方法实现定义好的event_dispatcher接口;
|
||||
|
||||
第二道,仔细研读channel_map实现中的map_make_space部分,说说你的理解。
|
||||
|
||||
欢迎你在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。
|
||||
304
极客时间专栏/网络编程实战/第四模块:实战篇/33 | 自己动手写高性能HTTP服务器(二):I|O模型和多线程模型实现.md
Normal file
304
极客时间专栏/网络编程实战/第四模块:实战篇/33 | 自己动手写高性能HTTP服务器(二):I|O模型和多线程模型实现.md
Normal 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->started);
|
||||
assertInSameThread(threadPool->mainLoop);
|
||||
|
||||
threadPool->started = 1;
|
||||
void *tmp;
|
||||
|
||||
if (threadPool->thread_number <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
threadPool->eventLoopThreads = malloc(threadPool->thread_number * sizeof(struct event_loop_thread));
|
||||
for (int i = 0; i < threadPool->thread_number; ++i) {
|
||||
event_loop_thread_init(&threadPool->eventLoopThreads[i], i);
|
||||
event_loop_thread_start(&threadPool->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(&eventLoopThread->thread_tid, NULL, &event_loop_thread_run, eventLoopThread);
|
||||
|
||||
assert(pthread_mutex_lock(&eventLoopThread->mutex) == 0);
|
||||
|
||||
while (eventLoopThread->eventLoop == NULL) {
|
||||
assert(pthread_cond_wait(&eventLoopThread->cond, &eventLoopThread->mutex) == 0);
|
||||
}
|
||||
assert(pthread_mutex_unlock(&eventLoopThread->mutex) == 0);
|
||||
|
||||
yolanda_msgx("event loop thread started, %s", eventLoopThread->thread_name);
|
||||
return eventLoopThread->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(&eventLoopThread->mutex);
|
||||
|
||||
// 初始化化event loop,之后通知主线程
|
||||
eventLoopThread->eventLoop = event_loop_init();
|
||||
yolanda_msgx("event loop thread init and signal, %s", eventLoopThread->thread_name);
|
||||
pthread_cond_signal(&eventLoopThread->cond);
|
||||
|
||||
pthread_mutex_unlock(&eventLoopThread->mutex);
|
||||
|
||||
//子线程event loop run
|
||||
eventLoopThread->eventLoop->thread_name = eventLoopThread->thread_name;
|
||||
event_loop_run(eventLoopThread->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->owner_thread_id = pthread_self();
|
||||
if (socketpair(AF_UNIX, SOCK_STREAM, 0, eventLoop->socketPair) < 0) {
|
||||
LOG_ERR("socketpair set fialed");
|
||||
}
|
||||
eventLoop->is_handle_pending = 0;
|
||||
eventLoop->pending_head = NULL;
|
||||
eventLoop->pending_tail = NULL;
|
||||
eventLoop->thread_name = "main thread";
|
||||
|
||||
struct channel *channel = channel_new(eventLoop->socketPair[1], EVENT_READ, handleWakeup, NULL, eventLoop);
|
||||
event_loop_add_channel_event(eventLoop, eventLoop->socketPair[1], channel);
|
||||
|
||||
return eventLoop;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
要特别注意的是这句代码,这告诉event_loop的,是注册了socketPair[1]描述字上的READ事件,如果有READ事件发生,就调用handleWakeup函数来完成事件处理。
|
||||
|
||||
```
|
||||
struct channel *channel = channel_new(eventLoop->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->socketPair[1], &one, sizeof one);
|
||||
if (n != sizeof one) {
|
||||
LOG_ERR("handleWakeup failed");
|
||||
}
|
||||
yolanda_msgx("wakeup, %s", eventLoop->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->acceptor;
|
||||
int listenfd = acceptor->listen_fd;
|
||||
|
||||
struct sockaddr_in client_addr;
|
||||
socklen_t client_len = sizeof(client_addr);
|
||||
//获取这个已建立的套集字,设置为非阻塞套集字
|
||||
int connected_fd = accept(listenfd, (struct sockaddr *) &client_addr, &client_len);
|
||||
make_nonblocking(connected_fd);
|
||||
|
||||
yolanda_msgx("new connection established, socket == %d", connected_fd);
|
||||
|
||||
//从线程池里选择一个eventloop来服务这个新的连接套接字
|
||||
struct event_loop *eventLoop = thread_pool_get_loop(tcpServer->threadPool);
|
||||
|
||||
// 为这个新建立套接字创建一个tcp_connection对象,并把应用程序的callback函数设置给这个tcp_connection对象
|
||||
struct tcp_connection *tcpConnection = tcp_connection_new(connected_fd, eventLoop,tcpServer->connectionCompletedCallBack,tcpServer->connectionClosedCallBack,tcpServer->messageCallBack,tcpServer->writeCompletedCallBack);
|
||||
//callback内部使用
|
||||
if (tcpServer->data != NULL) {
|
||||
tcpConnection->data = tcpServer->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->channel = channel1;
|
||||
|
||||
//完成对connectionCompleted的函数回调
|
||||
if (tcpConnection->connectionCompletedCallBack != NULL) {
|
||||
tcpConnection->connectionCompletedCallBack(tcpConnection);
|
||||
}
|
||||
|
||||
//把该套集字对应的channel对象注册到event_loop事件分发器上
|
||||
event_loop_add_channel_event(tcpConnection->eventLoop, connected_fd, tcpConnection->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(&eventLoop->mutex);
|
||||
assert(eventLoop->is_handle_pending == 0);
|
||||
//往该线程的channel列表里增加新的channel
|
||||
event_loop_channel_buffer_nolock(eventLoop, fd, channel1, type);
|
||||
//release the lock
|
||||
pthread_mutex_unlock(&eventLoop->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->eventDispatcher;
|
||||
|
||||
if (eventLoop->owner_thread_id != pthread_self()) {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
yolanda_msgx("event loop run, %s", eventLoop->thread_name);
|
||||
struct timeval timeval;
|
||||
timeval.tv_sec = 1;
|
||||
|
||||
while (!eventLoop->quit) {
|
||||
//block here to wait I/O event, and get active channels
|
||||
dispatcher->dispatch(eventLoop, &timeval);
|
||||
|
||||
//这里处理pending channel,如果是子线程被唤醒,这个部分也会立即执行到
|
||||
event_loop_handle_pending_channel(eventLoop);
|
||||
}
|
||||
|
||||
yolanda_msgx("event loop end, %s", eventLoop->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的算法,你觉得有没有改进的空间?如果改进的话,你可能会怎么做?
|
||||
|
||||
欢迎在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,一起交流进步一下。
|
||||
@@ -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) >= size) {
|
||||
return;
|
||||
}
|
||||
//如果front_spare和writeable的大小加起来可以容纳数据,则把可读数据往前面拷贝
|
||||
if (buffer_front_spare_size(buffer) + buffer_writeable_size(buffer) >= size) {
|
||||
int readable = buffer_readable_size(buffer);
|
||||
int i;
|
||||
for (i = 0; i < readable; i++) {
|
||||
memcpy(buffer->data + i, buffer->data + buffer->readIndex + i, 1);
|
||||
}
|
||||
buffer->readIndex = 0;
|
||||
buffer->writeIndex = readable;
|
||||
} else {
|
||||
//扩大缓冲区
|
||||
void *tmp = realloc(buffer->data, buffer->total_size + size);
|
||||
if (tmp == NULL) {
|
||||
return;
|
||||
}
|
||||
buffer->data = tmp;
|
||||
buffer->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->input_buffer;
|
||||
struct channel *channel = tcpConnection->channel;
|
||||
|
||||
if (buffer_socket_read(input_buffer, channel->fd) > 0) {
|
||||
//应用程序真正读取Buffer里的数据
|
||||
if (tcpConnection->messageCallBack != NULL) {
|
||||
tcpConnection->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->data + buffer->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 < 0) {
|
||||
return -1;
|
||||
} else if (result <= max_writable) {
|
||||
buffer->writeIndex += result;
|
||||
} else {
|
||||
buffer->writeIndex = buffer->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->data + buffer->readIndex, size);
|
||||
buffer->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->channel;
|
||||
struct buffer *output_buffer = tcpConnection->output_buffer;
|
||||
|
||||
//先往套接字尝试发送数据
|
||||
if (!channel_write_event_registered(channel) && buffer_readable_size(output_buffer) == 0) {
|
||||
nwrited = write(channel->fd, data, size);
|
||||
if (nwrited >= 0) {
|
||||
nleft = nleft - nwrited;
|
||||
} else {
|
||||
nwrited = 0;
|
||||
if (errno != EWOULDBLOCK) {
|
||||
if (errno == EPIPE || errno == ECONNRESET) {
|
||||
fault = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!fault && nleft > 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("get message from tcp connection %s", tcpConnection->name);
|
||||
|
||||
struct http_request *httpRequest = (struct http_request *) tcpConnection->request;
|
||||
struct http_server *httpServer = (struct http_server *) tcpConnection->data;
|
||||
|
||||
if (parse_http_request(input, httpRequest) == 0) {
|
||||
char *error_response = "HTTP/1.1 400 Bad Request\r\n\r\n";
|
||||
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->requestCallback != NULL) {
|
||||
httpServer->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->current_state != REQUEST_DONE) {
|
||||
if (httpRequest->current_state == REQUEST_STATUS) {
|
||||
char *crlf = buffer_find_CRLF(input);
|
||||
if (crlf) {
|
||||
int request_line_size = process_status_line(input->data + input->readIndex, crlf, httpRequest);
|
||||
if (request_line_size) {
|
||||
input->readIndex += request_line_size; // request line size
|
||||
input->readIndex += 2; //CRLF size
|
||||
httpRequest->current_state = REQUEST_HEADERS;
|
||||
}
|
||||
}
|
||||
} else if (httpRequest->current_state == REQUEST_HEADERS) {
|
||||
char *crlf = buffer_find_CRLF(input);
|
||||
if (crlf) {
|
||||
/**
|
||||
* <start>-------<colon>:-------<crlf>
|
||||
*/
|
||||
char *start = input->data + input->readIndex;
|
||||
int request_line_size = crlf - start;
|
||||
char *colon = memmem(start, request_line_size, ": ", 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->readIndex += request_line_size; //request line size
|
||||
input->readIndex += 2; //CRLF size
|
||||
} else {
|
||||
//读到这里说明:没找到,就说明这个是最后一行
|
||||
input->readIndex += 2; //CRLF size
|
||||
httpRequest->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, "HTTP/1.1 %d ", httpResponse->statusCode);
|
||||
buffer_append_string(output, buf);
|
||||
buffer_append_string(output, httpResponse->statusMessage);
|
||||
buffer_append_string(output, "\r\n");
|
||||
|
||||
if (httpResponse->keep_connected) {
|
||||
buffer_append_string(output, "Connection: close\r\n");
|
||||
} else {
|
||||
snprintf(buf, sizeof buf, "Content-Length: %zd\r\n", strlen(httpResponse->body));
|
||||
buffer_append_string(output, buf);
|
||||
buffer_append_string(output, "Connection: Keep-Alive\r\n");
|
||||
}
|
||||
|
||||
if (httpResponse->response_headers != NULL && httpResponse->response_headers_number > 0) {
|
||||
for (int i = 0; i < httpResponse->response_headers_number; i++) {
|
||||
buffer_append_string(output, httpResponse->response_headers[i].key);
|
||||
buffer_append_string(output, ": ");
|
||||
buffer_append_string(output, httpResponse->response_headers[i].value);
|
||||
buffer_append_string(output, "\r\n");
|
||||
}
|
||||
}
|
||||
|
||||
buffer_append_string(output, "\r\n");
|
||||
buffer_append_string(output, httpResponse->body);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 完整的HTTP服务器例子
|
||||
|
||||
现在,编写一个HTTP服务器例子就变得非常简单。
|
||||
|
||||
在这个例子中,最主要的部分是onRequest callback函数,这里,onRequest方法已经在parse_http_request之后,可以根据不同的http_request的信息,进行计算和处理。例子程序里的逻辑非常简单,根据http request的URL path,返回了不同的http_response类型。比如,当请求为根目录时,返回的是200和HTML格式。
|
||||
|
||||
```
|
||||
#include <lib/acceptor.h>
|
||||
#include <lib/http_server.h>
|
||||
#include "lib/common.h"
|
||||
#include "lib/event_loop.h"
|
||||
|
||||
//数据读到buffer之后的callback
|
||||
int onRequest(struct http_request *httpRequest, struct http_response *httpResponse) {
|
||||
char *url = httpRequest->url;
|
||||
char *question = memmem(url, strlen(url), "?", 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, "/") == 0) {
|
||||
httpResponse->statusCode = OK;
|
||||
httpResponse->statusMessage = "OK";
|
||||
httpResponse->contentType = "text/html";
|
||||
httpResponse->body = "<html><head><title>This is network programming</title></head><body><h1>Hello, network programming</h1></body></html>";
|
||||
} else if (strcmp(path, "/network") == 0) {
|
||||
httpResponse->statusCode = OK;
|
||||
httpResponse->statusMessage = "OK";
|
||||
httpResponse->contentType = "text/plain";
|
||||
httpResponse->body = "hello, network programming";
|
||||
} else {
|
||||
httpResponse->statusCode = NotFound;
|
||||
httpResponse->statusMessage = "Not Found";
|
||||
httpResponse->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)
|
||||
> GET / HTTP/1.1
|
||||
> Host: 127.0.0.1:43211
|
||||
> User-Agent: curl/7.54.0
|
||||
> Accept: */*
|
||||
>
|
||||
< HTTP/1.1 200 OK
|
||||
< Content-Length: 116
|
||||
< Connection: Keep-Alive
|
||||
<
|
||||
* Connection #0 to host 127.0.0.1 left intact
|
||||
<html><head><title>This is network programming</title></head><body><h1>Hello, network programming</h1></body></html>%
|
||||
|
||||
```
|
||||
|
||||
<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路径时,返回一张图片。
|
||||
|
||||
第二道,在我们的开发中,已经有很多面向对象的设计,你可以仔细研读代码,说说你对这部分的理解。
|
||||
|
||||
欢迎你在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。
|
||||
380
极客时间专栏/网络编程实战/第四模块:实战篇/35 | 答疑:编写高性能网络编程框架时,都需要注意哪些问题?.md
Normal file
380
极客时间专栏/网络编程实战/第四模块:实战篇/35 | 答疑:编写高性能网络编程框架时,都需要注意哪些问题?.md
Normal 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&A的形式来展开。
|
||||
|
||||
## 为什么在发送数据时,会先尝试通过socket直接发送,再由框架接管呢?
|
||||
|
||||
这个问题具体描述是下面这样的。
|
||||
|
||||
当应用程序需要发送数据时,比如下面这段,在完成数据读取和回应的编码之后,会调用tcp_connection_send_buffer方法发送数据。
|
||||
|
||||
```
|
||||
//数据读到buffer之后的callback
|
||||
int onMessage(struct buffer *input, struct tcp_connection *tcpConnection) {
|
||||
printf("get message from tcp connection %s\n", tcpConnection->name);
|
||||
printf("%s", input->data);
|
||||
|
||||
struct buffer *output = buffer_new();
|
||||
int size = buffer_readable_size(input);
|
||||
for (int i = 0; i < size; i++) {
|
||||
buffer_append_char(output, rot13_char(buffer_read_char(input)));
|
||||
}
|
||||
tcp_connection_send_buffer(tcpConnection, output);
|
||||
return 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
而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->data + buffer->readIndex, size);
|
||||
buffer->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->channel;
|
||||
struct buffer *output_buffer = tcpConnection->output_buffer;
|
||||
|
||||
//先往套接字尝试发送数据
|
||||
if (!channel_write_event_is_enabled(channel) && buffer_readable_size(output_buffer) == 0) {
|
||||
nwrited = write(channel->fd, data, size);
|
||||
if (nwrited >= 0) {
|
||||
nleft = nleft - nwrited;
|
||||
} else {
|
||||
nwrited = 0;
|
||||
if (errno != EWOULDBLOCK) {
|
||||
if (errno == EPIPE || errno == ECONNRESET) {
|
||||
fault = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!fault && nleft > 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->data + buffer->writeIndex, data, size);
|
||||
buffer->writeIndex += size;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
但是,如果增加了一段判断来直接往套接字发送,其实就跳过了这段拷贝,直接把数据发往到了套接字发生缓冲区。
|
||||
|
||||
```
|
||||
//先往套接字尝试发送数据
|
||||
if (!channel_write_event_is_enabled(channel) && buffer_readable_size(output_buffer) == 0) {
|
||||
nwrited = write(channel->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->eventLoop;
|
||||
assertInSameThread(eventLoop);
|
||||
|
||||
struct buffer *output_buffer = tcpConnection->output_buffer;
|
||||
struct channel *channel = tcpConnection->channel;
|
||||
|
||||
ssize_t nwrited = write(channel->fd, output_buffer->data + output_buffer->readIndex,buffer_readable_size(output_buffer));
|
||||
if (nwrited > 0) {
|
||||
//已读nwrited字节
|
||||
output_buffer->readIndex += nwrited;
|
||||
//如果数据完全发送出去,就不需要继续了
|
||||
if (buffer_readable_size(output_buffer) == 0) {
|
||||
channel_write_event_disable(channel);
|
||||
}
|
||||
//回调writeCompletedCallBack
|
||||
if (tcpConnection->writeCompletedCallBack != NULL) {
|
||||
tcpConnection->writeCompletedCallBack(tcpConnection);
|
||||
}
|
||||
} else {
|
||||
yolanda_msgx("handle_write for tcp connection %s", tcpConnection->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->channel = channel1;
|
||||
|
||||
//connectionCompletedCallBack callback
|
||||
if (tcpConnection->connectionCompletedCallBack != NULL) {
|
||||
tcpConnection->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->input_buffer;
|
||||
struct channel *channel = tcpConnection->channel;
|
||||
|
||||
if (buffer_socket_read(input_buffer, channel->fd) > 0) {
|
||||
//应用程序真正读取Buffer里的数据
|
||||
if (tcpConnection->messageCallBack != NULL) {
|
||||
tcpConnection->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->eventLoop;
|
||||
assertInSameThread(eventLoop);
|
||||
|
||||
struct buffer *output_buffer = tcpConnection->output_buffer;
|
||||
struct channel *channel = tcpConnection->channel;
|
||||
|
||||
ssize_t nwrited = write(channel->fd, output_buffer->data + output_buffer->readIndex,buffer_readable_size(output_buffer));
|
||||
if (nwrited > 0) {
|
||||
//已读nwrited字节
|
||||
output_buffer->readIndex += nwrited;
|
||||
//如果数据完全发送出去,就不需要继续了
|
||||
if (buffer_readable_size(output_buffer) == 0) {
|
||||
channel_write_event_disable(channel);
|
||||
}
|
||||
//回调writeCompletedCallBack
|
||||
if (tcpConnection->writeCompletedCallBack != NULL) {
|
||||
tcpConnection->writeCompletedCallBack(tcpConnection);
|
||||
}
|
||||
} else {
|
||||
yolanda_msgx("handle_write for tcp connection %s", tcpConnection->name);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## tcp_connection对象设计的想法是什么,和channel有什么联系和区别?
|
||||
|
||||
tcp_connection对象似乎和channel对象有着非常紧密的联系,为什么要单独设计一个tcp_connection呢?
|
||||
|
||||
我也提到了,开始的时候我并不打算设计一个tcp_connection对象的,后来我才发现非常有必要存在一个tcp_connection对象。
|
||||
|
||||
第一,我需要在暴露给应用程序的onMessage,onConnectionCompleted等回调函数里,传递一个有用的数据结构,这个数据结构必须有一定的现实语义,可以携带一定的信息,比如套接字、缓冲区等,而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(&eventLoopThread->thread_tid, NULL, &event_loop_thread_run, eventLoopThread);
|
||||
|
||||
assert(pthread_mutex_lock(&eventLoopThread->mutex) == 0);
|
||||
|
||||
while (eventLoopThread->eventLoop == NULL) {
|
||||
assert(pthread_cond_wait(&eventLoopThread->cond, &eventLoopThread->mutex) == 0);
|
||||
}
|
||||
assert(pthread_mutex_unlock(&eventLoopThread->mutex) == 0);
|
||||
|
||||
yolanda_msgx("event loop thread started, %s", eventLoopThread->thread_name);
|
||||
return eventLoopThread->eventLoop;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
要回答这个问题,就要解释多线程下共享变量竞争的问题。我们知道,一个共享变量在多个线程下同时作用,如果没有锁的控制,就会引起变量的不同步。这里的共享变量就是每个eventLoopThread的eventLoop对象。
|
||||
|
||||
这里如果我们不加锁,一直循环判断每个eventLoopThread的状态,会对CPU增加很大的消耗,如果使用锁-信号量的方式来加以解决,就变得很优雅,而且不会对CPU造成过多的影响。
|
||||
|
||||
## 关于channel_map的设计,特别是内存方面的设计。
|
||||
|
||||
我们来详细介绍一下channel_map。
|
||||
|
||||
channel_map实际上是一个指针数组,这个数组里面的每个元素都是一个指针,指向了创建出的channel对象。我们用数据下标和套接字进行了映射,这样虽然有些元素是浪费了,比如stdin,stdout,stderr代表的套接字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值,这样既高效也节省内存。
|
||||
|
||||
## 总结
|
||||
|
||||
以上就是实战篇中一些同学的疑问。
|
||||
|
||||
在这篇文章之后,我们的专栏就告一段落了,我希望这个专栏可以帮你梳理清楚高性能网络编程的方方面面,如果你能从中有所领悟,或者帮助你在面试中拿到好的结果,我会深感欣慰。
|
||||
|
||||
如果你觉得今天的答疑内容对你有所帮助,欢迎把它转发给你的朋友或者同事,一起交流一下。
|
||||
53
极客时间专栏/网络编程实战/结束语/结束语丨我相信这不是结束,让我们江湖再见.md
Normal file
53
极客时间专栏/网络编程实战/结束语/结束语丨我相信这不是结束,让我们江湖再见.md
Normal 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)
|
||||
Reference in New Issue
Block a user