80-网络编程实战

开篇词 | 学好网络编程，需要掌握哪些核心问题？

你好，我是盛延敏，欢迎你的加入。在接下来的几个月时间里，我会和你一起深入学习网络编程。网络编程是一个高度重视实战的内容模块，工作这么多年，我一直都在近距离与它打交道。

我博士毕业于中科大，毕业之后就加入了 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 的重要性愈发明显。对有志于学习网络编程的你来说，这是一个非常明智的选择。

我在下面放了一张专栏的目录，专栏将分别从基础篇、提升篇、性能篇和实战篇展开。

在我看来，要学好网络编程，需要达到以下三个层次。

第一个层次，充分理解 TCP/IP 网络模型和协议。在这方面，仅仅做到理论上的理解是远远不够的。在基础篇中我们会梳理 TCP/IP 模型和网络函数接口之间的联系，并通过实例展开对套接字，套接字缓冲区，拥塞控制，数据包和数据流，本地套接字（UNIX 域套接字）等的讨论，给你一个全面而具体的知识体系。

第二个层次，结合对协议的理解，增强对各种异常情况的优雅处理能力。比如对 TCP 数据流的处理，半关闭的连接，TCP 连接有效性的侦测，处理各种异常情况等，这些问题决定了程序的健壮性。有关这一部分的内容，我们将在提升篇详细展开。

第三个层次，写出可以支持大规模高并发的网络处理程序。在这个阶段，我将带你一起深入研究 C10K 问题，引入进程、线程、多路复用、非阻塞、异步、事件驱动等现代高性能网络编程所需要的技术。我们将在性能篇深入讨论这些技术，并在实战篇结合实例，一步步教你写出高性能的网络程序。

“不闻不若闻之，闻之不若见之，见之不若知之，知之不若行之。”几千年前荀子的这段话，告诉了我们学习计算机编程技术的最佳方法，我想用在这里再合适不过了。

所以，无论是哪个层次，都需要你和我一起坚持下去。你可以对专栏里的代码进行修改和调试，从而增强你对网络编程的理解。

最后，请你留言说一说自己的情况。你在什么岗位？工作几年啦？在日常工作中是怎么使用网络编程的？开张圣听，期待跟你碰撞出更多精彩想法。感谢你对我的信任，我一定会竭尽所能，助你成功。

让我们一起进步。

01 | 追古溯源：TCP/IP和Linux是如何改变世界的？

你好，我是盛延敏。今天是网络编程课程的第一课，我想你一定满怀热情，期望快速进入到技术细节里，了解那些你不熟知的编程技能。而今天我却想和你讲讲历史，虽然这些事情看着不是“干货”，但它可以帮助你理解网络编程中各种技术的来龙去脉。

你我都是程序员，说句实在话，我们正处于一个属于我们的时代里，我们也正在第一线享受着这个时代的红利。在我看来，人类历史上还从来没有一项技术可以像互联网一样深刻地影响人们生活的方方面面。

而具体到互联网技术里，有两件事最为重要，一个是 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 参考模型了，你可以阅读罗剑锋老师的专栏，他讲得很好。

这个标准发布的时候已经是 1984 年了，有点尴尬的是，OSI 搞得是很好，大家也都很满意，不过，等它发布的时候，ISO 组织却惊讶地发现满世界都在用一个叫做 TCP/IP 协议栈的东西，而且，跟 OSI 标准半毛钱关系都没有。

这就涉及到了另一头——TCP/IP 的发展。

事实上，我在前面提到的那两位牛人卡恩和瑟夫，一直都在不遗余力地推广 TCP/IP。当然，TCP/IP 的成功也不是偶然的，而是综合了几个因素后的结果：

TCP/IP 是免费或者是少量收费的，这样就扩大了使用人群；

TCP/IP 搭上了 UNIX 这辆时代快车，很快推出了基于套接字（socket）的实际编程接口；

这是最重要的一点，TCP/IP 来源于实际需求，大家都在翘首盼望出一个统一标准，可是在此之前实际的问题总要解决啊，TCP/IP 解决了实际问题，并且在实际中不断完善。

回过来看，OSI 的七层模型定得过于复杂，并且没有参考实现，在一定程度上阻碍了普及。

不过，OSI 教科书般的层次模型，对后世的影响很深远，一般我们说的 4 层、7 层，也是遵从了 OSI 模型的定义，分别指代传输层和应用层。

我们说 TCP/IP 的应用层对应了 OSI 的应用层、表示层和会话层；TCP/IP 的网络接口层对应了 OSI 的数据链路层和物理层。

UNIX 操作系统发展历史

前面我们提到了 TCP/IP 协议的成功，离不开 UNIX 操作系统的发展。接下来我们就看下 UNIX 操作系统是如何诞生和演变的。

下面这张图摘自维基百科，它将 Unix 操作系统几十年的发展历史表述得非常清楚。

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 的历史表达得更为详细。

一个基本事实是，网络编程套接字接口，最早是在 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 逐渐成为事实标准的这一历史进程。

Mac OS X

用 Mac 笔记本的同学都有这样的感觉：Mac OS 提供的环境和 Linux 环境非常像，很多代码可以在 Mac 上以接近线上 Linux 真实环境的方式运行。

有心的同学应该想过背后有一定的原因。

答案其实很简单，Mac OS 和 Linux 的血缘是相近的，它们都是 UNIX 基础上发展起来的，或者说，它们各自就是一个类 UNIX 的系统。

Mac 系统又被称为 Darwin，它已被验证过就是一个 UNIX 操作系统。如果打开 Mac 系统的 socket.h 头文件定义，你会明显看到 Mac 系统和 BSD 千丝万缕的联系，说明这就是从 BSD 系统中移植到 Mac 系统来的。

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 的发展提供了非常好的指引。这也是为什么我们的程序在 Mac OS 和 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 的操作系统的。

From CSvax:pur-ee:inuxc!ixn5c!ihnp4!houxm!mhuxi!eagle!mit-vax!mit-eddie!RMS@ MIT-OZ
From: RMS% MIT-OZ@ mit-eddie
Newsgroups: net.unix-wizards,net.usoft
Subj ect: new UNIX implementation
Date: Tue, 27-Sep-83 12:35:59 EST
Organization: MIT AI Lab, Cambridge, MA
Free Unix!
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.
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.
…

在这个设想宏大的 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 网络这块，也只能算是一个后来者。

总结

这是我们专栏的第一讲，我没有直接开始讲网络编程，而是对今天互联网技术的基石，TCP 和 Linux 进行了简单的回顾。通过这样的回顾，熟悉历史，可以指导我们今后学习的方向，在后面的章节中，我们都将围绕 Linux 下的 TCP/IP 程序设计展开。

最后你不妨思考一下，Linux TCP/IP 网络协议栈最初的实现“借鉴”了多少 BSD 的实现呢？Linux 到底是不是应该被称为 GNU/Linux 呢？

欢迎你在评论区写下你的思考，我会和你一起讨论这些问题。如果这篇文章帮你厘清了 TCP/IP 和 Linux 的发展脉络，欢迎把它分享给你的朋友或者同事。

02 | 网络编程模型：认识客户端-服务器网络模型的基本概念

你好，我是盛延敏。上一讲我们学习了 TCP/IP 的创建和历史，以及 Linux 操作系统的建立和发展，相信你对网络编程这棵大树已经有了一个宏观上的认识，那么今天我们再往前走几步，近距离看看这棵大树的细枝末节到底是怎样的。

从哪里开始呢？从网络编程的基本概念开始说起吧。

客户端 - 服务器网络编程模型

在谈论网络编程时，我们首先需要建立一个概念，也就是我们今天的主题“客户端 - 服务器”。

拿我们常用的网络购物来说，我们在手机上的每次操作，都是作为客户端向服务器发送请求，并收到响应的例子。

这个过程具体阐释如下：

当一个客户端需要服务时，比如网络购物下单，它会向服务器端发送一个请求。注意，这个请求是按照双方约定的格式来发送的，以便保证服务器端是可以理解的；

服务器端收到这个请求后，会根据双方约定的格式解释它，并且以合适的方式进行操作，比如调用数据库操作来创建一个购物单；

服务器端完成处理请求之后，会给客户端发送一个响应，比如向客户端发送购物单的实际付款额，然后等待客户端的下一步操作；

客户端收到响应并进行处理，比如在手机终端上显示该购物单的实际付款额，并且让用户选择付款方式。

在网络编程中，具体到客户端 - 服务器模型时，我们经常会考虑是使用 TCP 还是 UDP，其实它们二者的区别也很简单：TCP 中连接是谁发起的，在 UDP 中报文是谁发送的。在 TCP 通信中，建立连接是一个非常重要的环节。区别出客户端和服务器，本质上是因为二者编程模型是不同的。

服务器端需要在一开始就监听在一个众所周知的端口上，等待客户端发送请求，一旦有客户端连接建立，服务器端就会消耗一定的计算机资源为它服务，服务器端是需要同时为成千上万的客户端服务的。如何保证服务器端在数据量巨大的客户端访问时依然能维持效率和稳定，这也是我们讲述高性能网络编程的目的。

客户端相对来说更为简单，它向服务器端的监听端口发起连接请求，连接建立之后，通过连接通路和服务器端进行通信。

还有一点需要强调的是，无论是客户端，还是服务器端，它们运行的单位都是进程（process），而不是机器。一个客户端，比如我们的手机终端，同一个时刻可以建立多个到不同服务器的连接，比如同时打游戏，上知乎，逛天猫；而服务器端更是可能在一台机器上部署运行了多个服务，比如同时开启了 SSH 服务和 HTTP 服务。

IP 和端口

正如寄信需要一个地址一样，在网络世界里，同样也需要地址的概念。在 TCP/IP 协议栈中，IP 用来表示网络世界的地址。

前面我们提到了，在一台计算机上是可以同时存在多个连接的，那么如何区分出不同的连接呢？

这里就必须提到端口这个概念。我们拿住酒店举例子，酒店的地址是唯一的，每间房间的号码是不同的，类似的，计算机的 IP 地址是唯一的，每个连接的端口号是不同的。

端口号是一个 16 位的整数，最多为 65536。当一个客户端发起连接请求时，客户端的端口是由操作系统内核临时分配的，称为临时端口；然而，前面也提到过，服务器端的端口通常是一个众所周知的端口。

一个连接可以通过客户端 - 服务器端的 IP 和端口唯一确定，这叫做套接字对，按照下面的四元组表示：

（clientaddr:clientport, serveraddr: serverport)

下图表示了一个客户端 - 服务器之间的连接：

保留网段

一个比较常见的现象是，我们所在的单位或者组织，普遍会使用诸如 10.0.x.x 或者 192.168.x.x 这样的 IP 地址，你可能会纳闷，这样的 IP 到底代表了什么呢？不同的组织使用同样的 IP 会不会导致冲突呢?

背后的原因是这样的，国际标准组织在 IPv4 地址空间里面，专门划出了一些网段，这些网段不会用做公网上的 IP，而是仅仅保留做内部使用，我们把这些地址称作保留网段。

下表是三个保留网段，其可以容纳的计算机主机个数分别是 16777216 个、1048576 个和 65536 个。

在详细讲述这个表格之前，我们需要首先了解一下子网掩码的概念。

子网掩码

在网络 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 类）网络。

我们再来重新看一下这张表格，表格第一行就是这样的一个 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，www.tinghua.edu.cn等。

数据报和字节流

尽管名称是 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 地址、端口、子网掩码和域名等基础概念，以下知识点你需要重点关注一下：

网络编程需要牢牢树立起“客户端”和“服务器”模型，两者编程的方法和框架是明显不同的。

TCP 连接是客户端 - 服务器的 IP 和端口四元组唯一确定的，IP 是一台机器在网络世界的唯一标识。

有两种截然不同的传输层协议，面向连接的“数据流”协议 TCP，以及无连接的“数据报”协议 UDP。

从下一讲开始，我们将开始使用套接字编写我们的第一个客户端 - 服务器程序。

思考题

最后给你布置几个思考题。

我们看到保留地址中第二行 172.16.0.0/12 描述为 16 个连续的 B 段，第三行 192.168.0.0/16 描述为 256 个连续的 C 段地址，怎么理解这种描述呢？

另外，章节里提到了服务端必须侦听在一个众所周知的端口上，这个端口怎么选择，又是如何让客户端知道的呢？

如果你仔细想过这个问题，欢迎在评论区写在你的思考，也欢迎把这篇文章分享给你的朋友或者同事，大家一起交流一下。

03丨套接字和地址：像电话和电话号码一样理解它们

在网络编程中，我们经常会提到 socket 这个词，它的中文翻译为套接字，有的时候也叫做套接口。

socket 这个英文单词的原意是“插口”“插槽”， 在网络编程中，它的寓意是可以通过插口接入的方式，快速完成网络连接和数据收发。你可以把它想象成现实世界的电源插口，或者是早期上网需要的网络插槽，所以 socket 也可以看做是对物理世界的直接映射。

其实计算机程序设计是一门和英文有着紧密联系的学科，很多专有名词使用英文原词比翻译成中文更容易让大家接受。为了方便，在专栏里我们一般会直接使用英文，如果需要翻译就一律用“套接字”这个翻译。

socket 到底是什么？

在网络编程中，到底应该怎么理解 socket 呢？我在这里先呈上这么一张图，你可以先看看。

这张图表达的其实是网络编程中，客户端和服务器工作的核心逻辑。

我们先从右侧的服务器端开始看，因为在客户端发起连接请求之前，服务器端必须初始化好。右侧的图显示的是服务器端初始化的过程，首先初始化 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 个字节，其中流控信息和域 IP 先不用管，这两个字段，一个在 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 套接字地址结构的长度是固定的，而本地地址结构的长度是可变的。

总结

这一讲我们重点讲述了什么是套接字，以及对应的套接字地址格式。套接字作为网络编程的基础，概念异常重要。套接字的设计为我们打开了网络编程的大门，实际上，正是因为 BSD 套接字如此成功，各大 Unix 厂商（包括开源的 Linux）以及 Windows 平台才会很快照搬了过来。在下一讲中，我们将开始创建并使用套接字，建立连接，进一步开始我们的网络编程之旅。

思考题

最后给你留两道思考题吧，你可以想一想 IPv4、IPv6、本地套接字格式以及通用地址套接字，它们有什么共性呢？如果你是 BSD 套接字的设计者，你为什么要这样设计呢？

第二道题是，为什么本地套接字格式不需要端口号，而 IPv4 和 IPv6 套接字格式却需要端口号呢？

我在评论区期待你的思考与见解，如果你觉得这篇文章对你有所帮助，欢迎点击“请朋友读”，把这篇文章分享给你朋友或同事。

04 | TCP三次握手：怎么使用套接字格式建立连接？

你好，我是盛延敏，这里是网络编程实战第 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)

我来稍微解释一下。第一个参数 socketfd 为套接字描述符，第二个参数 backlog，官方的解释为未完成连接队列的大小，这个参数的大小决定了可以接收的并发数目。这个参数越大，并发数目理论上也会越大。但是参数过大也会占用过多的系统资源，一些系统，比如 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 的三次握手过程，而且仅在连接建立成功或出错时才返回。其中出错返回可能有以下几种情况：

三次握手无法建立，客户端发出的 SYN 包没有任何响应，于是返回 TIMEOUT 错误。这种情况比较常见的原因是对应的服务端 IP 写错。

客户端收到了 RST（复位）回答，这时候客户端会立即返回 CONNECTION REFUSED 错误。这种情况比较常见于客户端发送连接请求时的请求端口写错，因为 RST 是 TCP 在发生错误时发送的一种 TCP 分节。产生 RST 的三个条件是：目的地为某端口的 SYN 到达，然而该端口上没有正在监听的服务器（如前所述）；TCP 想取消一个已有连接；TCP 接收到一个根本不存在的连接上的分节。

客户发出的 SYN 包在网络上引起了"destination unreachable"，即目的不可达的错误。这种情况比较常见的原因是客户端和服务器端路由不通。

根据不同的返回值，我们可以做进一步的排查。

著名的 TCP 三次握手: 这一次不用背记

你在各个场合都会了解到著名的 TCP 三次握手，可能还会被要求背下三次握手整个过程，但背后的原理和过程可能未必真正理解。我们刚刚学习了服务端和客户端连接的主要函数，下面结合这些函数讲解一下 TCP 三次握手的过程。这样我相信你不用背，也能根据理解轻松掌握这部分的知识。

这里我们使用的网络编程模型都是阻塞式的。所谓阻塞式，就是调用发起后不会直接返回，由操作系统内核处理之后才会返回。 相对的，还有一种叫做非阻塞式的，我们在后面的章节里会讲到。

TCP 三次握手的解读

我们先看一下最初的过程，服务器端通过 socket，bind 和 listen 完成了被动套接字的准备工作，被动的意思就是等着别人来连接，然后调用 accept，就会阻塞在这里，等待客户端的连接来临；客户端通过调用 socket 和 connect 函数之后，也会阻塞。接下来的事情是由操作系统内核完成的，更具体一点的说，是操作系统内核网络协议栈在工作。

下面是具体的过程：

客户端的协议栈向服务器端发送了 SYN 包，并告诉服务器端当前发送序列号 j，客户端进入 SYNC_SENT 状态；

服务器端的协议栈收到这个包之后，和客户端进行 ACK 应答，应答的值为 j+1，表示对 SYN 包 j 的确认，同时服务器也发送一个 SYN 包，告诉客户端当前我的发送序列号为 k，服务器端进入 SYNC_RCVD 状态；

客户端协议栈收到 ACK 之后，使得应用程序从 connect 调用返回，表示客户端到服务器端的单向连接建立成功，客户端的状态为 ESTABLISHED，同时客户端协议栈也会对服务器端的 SYN 包进行应答，应答数据为 k+1；

应答包到达服务器端后，服务器端协议栈使得 accept 阻塞调用返回，这个时候服务器端到客户端的单向连接也建立成功，服务器端也进入 ESTABLISHED 状态。

形象一点的比喻是这样的，有 A 和 B 想进行通话：

A 先对 B 说：“喂，你在么？我在的，我的口令是 j。”

B 收到之后大声回答：“我收到你的口令 j 并准备好了，你准备好了吗？我的口令是 k。”

A 收到之后也大声回答：“我收到你的口令 k 并准备好了，我们开始吧。”

可以看到，这样的应答过程总共进行了三次，这就是 TCP 连接建立之所以被叫为“三次握手”的原因了。

总结

这一讲我们分别从服务端和客户端的角度，讲述了如何创建套接字，并利用套接字完成 TCP 连接的建立。

服务器端通过创建 socket，bind，listen 完成初始化，通过 accept 完成连接的建立。

客户端通过场景 socket，connect 发起连接建立请求。

在下一讲里，我们将真正地开始客户端 - 服务端数据交互的过程。

思考题

最后给你布置两道思考题。

第一道是关于阻塞调用的，既然有阻塞调用，就应该有非阻塞调用，那么如何使用非阻塞调用套接字呢？使用的场景又是哪里呢？

第二道是关于客户端的，客户端发起 connect 调用之前，可以调用 bind 函数么？

欢迎你在评论区与我分享你的答案，如果这篇文章帮助你理解 TCP 三路握手，也欢迎你点击“请朋友读”，把这篇文章分享给你的朋友或者同事。

05 | 使用套接字进行读写：开始交流吧

你好，我是盛延敏，这里是网络编程实战第 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 阻塞调用返回。注意返回的时刻，应用程序数据并没有全部被发送出去，发送缓冲区里还有部分数据，这部分数据会在稍后由操作系统内核通过网络发送出去。

读取数据

我们可以注意到，套接字描述本身和本地文件描述符并无区别，在 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" 个字节. */

ssize_t readn(int fd, void *vptr, size_t size)

{

    size_t  nleft;

    ssize_t nread;

    char    *ptr;

    ptr = vptr;

    nleft = size;

    while (nleft > 0) {

        if ( (nread = read(fd, ptr, nleft)) < 0) {

            if (errno == EINTR)

                nread = 0;      /* 这里需要再次调用 read */

            else

                return(-1);

        } else if (nread == 0)

            break;              /* EOF(End of File) 表示套接字关闭 */

        nleft -= nread;

        ptr   += nread;

    }

    return(n - nleft);      /* 返回的是实际读取的字节数 */

}

对这个程序稍微解释下：

11-25 行的循环条件表示的是，在没读满 size 个字节之前，一直都要循环下去。

13-14 行表示的是非阻塞 I/O 的情况下，没有数据可以读，需要继续调用 read。

17-18 行表示读到对方发出的 FIN 包，表现形式是 EOF，此时需要关闭套接字。

21-22 行，需要读取的字符数减少，缓存指针往下移动。

24 行是在读取 EOF 跳出循环后，返回实际读取的字符数。

缓冲区实验

我们用一个客户端 - 服务器的例子来解释一下读取缓冲区和发送缓冲区的概念。在这个例子中客户端不断地发送数据，服务器端每读取一段数据之后进行休眠，以模拟实际业务处理所需要的时间。

服务器端读取数据程序

下面是服务器端读取数据的程序：

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, (SA *) &servaddr, sizeof(servaddr));

    /* listen 的 backlog 为 1024 */

    listen(listenfd, 1024);

    /* 循环处理用户请求 */

    for ( ; ; ) {

        clilen = sizeof(cliaddr);

        connfd = accept(listenfd, (SA *) &cliaddr, &clilen);

        read_data(connfd);   /* 读取数据 */

        close(connfd);          /* 关闭连接套接字，注意不是监听套接字 */

    }

}

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;     /* connection closed by other end */

        time ++;

        fprintf(stdout, "1K read for %d \n", time);

        usleep(1000);

    }

}

对服务器端程序解释如下：

6-17 行先后创建了 socket 套接字，bind 到对应地址和端口，并开始调用 listen 接口监听。

20-25 行循环等待连接，通过 accept 获取实际的连接，并开始读取数据。

28-42 行实际每次读取 1K 数据，之后休眠 1 秒，用来模拟服务器端处理时延。

客户端发送数据程序

下面是客户端发送数据的程序：

int main(int argc, char **argv)

{

    int                 sockfd;

    struct sockaddr_in  servaddr;

    if (argc != 2)

        err_quit("usage: tcpclient <IPaddress>");

    sockfd = socket(AF_INET, SOCK_STREAM, 0);

    bzero(&servaddr, sizeof(servaddr));

    servaddr.sin_family = AF_INET;

    servaddr.sin_port = htons(SERV_PORT);

    inet_pton(AF_INET, argv[1], &servaddr.sin_addr);

    connect(sockfd, (SA *) &servaddr, sizeof(servaddr));

    send_data(stdin, sockfd);

    exit(0);

}

# define MESSAGE_SIZE 10240000

void send_data(FILE *fp, 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;

    remaining = strlen(query);

    while (remaining) {

      n_written = send(sockfd, cp, remaining, 0);

      fprintf(stdout, "send into buffer %ld \n", n_written);

      if (n_written <= 0) {

        perror("send");

        return;

      }

      remaining -= n_written;

      cp += n_written;

    }

    return;

}

对客户端程序解释如下：

9-15 行先后创建了 socket 套接字，调用 connect 向对应服务器端发起连接请求。

16 行在连接建立成功后，调用 send_data 发送数据。

23-28 行初始化了一个长度为 MESSAGE_SIZE 的字符串流。

33-42 行调用 send 函数将 MESSAGE_SIZE 长度的字符串流发送出去。

实验一: 观察客户端数据发送行为

客户端程序发送了一个很大的字节流，程序运行起来之后，我们会看到服务端不断地在屏幕上打印出读取字节流的过程：

而客户端直到最后所有的字节流发送完毕才打印出下面的一句话，说明在此之前 send 函数一直都是阻塞的，也就是说阻塞式套接字最终发送返回的实际写入字节数和请求字节数是相等的。

而关于非阻塞套接字的操作，我会在后面的文章中讲解。

实验二: 服务端处理变慢

如果我们把服务端的休眠时间稍微调大，把客户端发送的字节数从从 10240000 调整为 1024000，再次运行刚才的例子，我们会发现，客户端很快打印出一句话：

但与此同时，服务端读取程序还在屏幕上不断打印读取数据的进度，显示出服务端读取程序还在辛苦地从缓冲区中读取数据。

通过这个例子我想再次强调一下：

发送成功仅仅表示的是数据被拷贝到了发送缓冲区中，并不意味着连接对端已经收到所有的数据。至于什么时候发送到对端的接收缓冲区，或者更进一步说，什么时候被对方应用程序缓冲所接收，对我们而言完全都是透明的。

总结

这一讲重点讲述了通过 send 和 read 来收发数据包，你需要牢记以下两点：

对于 send 来说，返回成功仅仅表示数据写到发送缓冲区成功，并不表示对端已经成功收到。

对于 read 来说，需要循环读取数据，并且需要考虑 EOF 等异常条件。

思考题

最后你不妨思考一下，既然缓冲区如此重要，我们可不可以把缓冲区搞得大大的，这样不就可以提高应用程序的吞吐量了么？你可以想一想这个方法可行吗？另外你可以自己总结一下，一段数据流从应用程序发送端，一直到应用程序接收端，总共经过了多少次拷贝？

欢迎你在评论区与我分享你的答案，如果你理解了套接字读写的过程，也欢迎把这篇文章分享给你的朋友或者同事。

06 | 嗨，别忘了UDP这个小兄弟

你好，我是盛延敏，这里是网络编程实战第 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 程序设计时的主要过程。

我们看到服务器端创建 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 这个协议。

07 | What? 还有本地套接字？

你好，我是盛延敏，这里是网络编程实战第 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 调用。

眼尖的同学可能发现列表里还有 docker-containerd.sock 等其他本地套接字，是的，Docker 其实也是大量使用了本地套接字技术来构建的。

如果我们在 /var/run 目录下将会看到 docker 使用的本地套接字描述符:

本地套接字概述

本地套接字一般也叫做 UNIX 域套接字，最新的规范已经改叫本地套接字。在前面的 TCP/UDP 例子中，我们经常使用 127.0.0.1 完成客户端进程和服务器端进程同时在本机上的通信，那么，这里的本地套接字又是什么呢？

本地套接字是一种特殊类型的套接字，和 TCP/UDP 套接字不同。TCP/UDP 即使在本地地址通信，也要走系统网络协议栈，而本地套接字，严格意义上说提供了一种单主机跨进程间调用的手段，减少了协议栈实现的复杂度，效率比 TCP/UDP 套接字都要高许多。类似的 IPC 机制还有 UNIX 管道、共享内存和 RPC 调用等。

比如 X Window 实现，如果发现是本地连接，就会走本地套接字，工作效率非常高。

现在你可以回忆一下，在前面介绍套接字地址时，我们讲到了本地地址，这个本地地址就是本地套接字专属的。

本地字节流套接字

我们先从字节流本地套接字开始。

这是一个字节流类型的本地套接字服务器端例子。在这个例子中，服务器程序打开本地套接字后，接收客户端发送来的字节流，并往客户端回送了新的字节流。

#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 这个文件路径上。

看看，很简单吧，我们写的程序和鼎鼎大名的 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

我们可以看到，服务器端陆续收到客户端发送的字节，同时，客户端也收到了服务器端的应答；最后，当我们使用 Ctrol+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 的字节流、数据报套接字实现。

思考题

讲完本地套接字之后，我给你留几道思考题。

在本地套接字字节流类型的客户端 - 服务器例子中，我们让服务器端以 root 账号启动，监听在 /var/lib/unixstream.sock 这个文件上。如果我们让客户端以普通用户权限启动，客户端可以连接上 /var/lib/unixstream.sock 吗？为什么呢？

我们看到客户端被杀死后，服务器端也正常退出了。看下退出后打印的日志，你不妨判断一下引起服务器端正常退出的逻辑是什么？

你有没有想过这样一个奇怪的场景：如果自己不小心写错了代码，本地套接字服务器端是 SOCK_DGRAM，客户端使用的是 SOCK_STREAM，路径和其他都是正确的，你觉得会发生什么呢？

欢迎你在评论区写下你的思考，我会和你一起交流这些问题。如果这篇文章帮你弄懂了本地套接字，不妨把它分享给你的朋友或者同事，一起交流一下它吧！

08 | 工欲善其事必先利其器：学会使用各种工具

你好，我是盛延敏，这里是网络编程实战第 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 协议的控制协议，翻译为网际控制协议，其报文格式如下图：

ICMP 在 IP 报文后加入了新的内容，这些内容包括：

类型：即 ICMP 的类型, 其中 ping 的请求类型为 0，应答为 8。

代码：进一步划分 ICMP 的类型, 用来查找产生错误的原因。

校验和：用于检查错误的数据。

标识符：通过标识符来确认是谁发送的控制协议，可以是进程 ID。

序列号：唯一确定的一个报文，前面 ping 名字执行后显示的 icmp_seq 就是这个值。

当我们发起 ping 命令时，ping 程序实际上会组装成如图的一个 IP 报文。报文的目的地址为 ping 的目标地址，源地址就是发送 ping 命令时的主机地址，同时按照 ICMP 报文格式填上数据，在可选数据上可以填上发送时的时间戳。

IP 报文通过 ARP 协议，源地址和目的地址被翻译成 MAC 地址，经过数据链路层后，报文被传输出去。当报文到达目的地址之后，目的地址所在的主机也按照 ICMP 协议进行应答。之所以叫做协议，是因为双方都会遵守这个报文格式，并且也会按照格式进行发送 - 应答。

应答数据到达源地址之后，ping 命令可以通过再次解析 ICMP 报文，对比序列号，计算时间戳等来完成每个发送 - 应答的显示，最终显示的格式就像前面的例子中展示的一样。

可以说，ICMP 协议为我们侦测网络问题提供了非常好的支持。另外一种对路由的检测命令 Traceroute 也是通过 ICMP 协议来完成的，这里就不展开讲了。

基本命令: ifconfig

很多熟悉 Windows 的同学都知道 Windows 有一个 ipconfig 命令，用来显示当前的网络设备列表。事实上，Linux 有一个对应的命令叫做 ifconfig，也用来显示当前系统中的所有网络设备，通俗一点的说，就是网卡列表。

vagrant@ubuntu-xenial-01:~$ ifconfig

cni0      Link encap:Ethernet  HWaddr 0a:58:0a:f4:00:01

          inet addr:10.244.0.1  Bcast:0.0.0.0  Mask:255.255.255.0

          inet6 addr: fe80::401:b4ff:fe51:bcf9/64 Scope:Link

          UP BROADCAST RUNNING MULTICAST  MTU:1450  Metric:1

          RX packets:2133 errors:0 dropped:0 overruns:0 frame:0

          TX packets:2216 errors:0 dropped:0 overruns:0 carrier:0

          collisions:0 txqueuelen:1000

          RX bytes:139381 (139.3 KB)  TX bytes:853302 (853.3 KB)

docker0   Link encap:Ethernet  HWaddr 02:42:93:0f:f7:11

          inet addr:172.17.0.1  Bcast:0.0.0.0  Mask:255.255.0.0

          inet6 addr: fe80::42:93ff:fe0f:f711/64 Scope:Link

          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1

          RX packets:653 errors:0 dropped:0 overruns:0 frame:0

          TX packets:685 errors:0 dropped:0 overruns:0 carrier:0

          collisions:0 txqueuelen:0

          RX bytes:49542 (49.5 KB)  TX bytes:430826 (430.8 KB)

enp0s3    Link encap:Ethernet  HWaddr 02:54:ad:ea:60:2e

          inet addr:10.0.2.15  Bcast:10.0.2.255  Mask:255.255.255.0

          inet6 addr: fe80::54:adff:feea:602e/64 Scope:Link

          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1

          RX packets:7951 errors:0 dropped:0 overruns:0 frame:0

          TX packets:4123 errors:0 dropped:0 overruns:0 carrier:0

          collisions:0 txqueuelen:1000

          RX bytes:5081047 (5.0 MB)  TX bytes:385600 (385.6 KB)

我稍微解释一下这里面显示的数据。

Link encap:Ethernet  HWaddr 02:54:ad:ea:60:2e

上面这段表明这是一个以太网设备，MAC 地址为 02:54:ad:ea:60:2e。

inet addr:10.0.2.15  Bcast:10.0.2.255  Mask:255.255.255.0

inet6 addr: fe80::54:adff:feea:602e/64 Scope:Link

这里显示的是网卡的 IPv4 和 IPv6 地址，其中 IPv4 还显示了该网络的子网掩码以及广播地址。

在每个 IPv4 子网中，有一个特殊地址被保留作为子网广播地址，比如这里的 10.0.2.255 就是这个子网的广播地址。当向这个地址发送请求时，就会向以太网网络上的一组主机发送请求。

通常来说，这种被称作广播（broadcast）的技术，是用 UDP 来实现的。

UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1

这里显示的是网卡的状态，MTU 是最大传输单元的意思，表示的是链路层包的大小。1500 表示的是字节大小。

Metric 大家可能不知道是干啥用的，这里解释下，Linux 在一台主机上可以有多个网卡设备，很可能有这么一种情况，多个网卡可以路由到目的地。一个简单的例子是在同时有无线网卡和有线网卡的情况下，网络连接是从哪一个网卡设备上出去的？Metric 就是用来确定多块网卡的优先级的，数值越小，优先级越高，1 为最高级。

          RX packets:7951 errors:0 dropped:0 overruns:0 frame:0

          TX packets:4123 errors:0 dropped:0 overruns:0 carrier:0

          collisions:0 txqueuelen:1000

          RX bytes:5081047 (5.0 MB)  TX bytes:385600 (385.6 KB)

netstat 和 lsof：对网络状况了如指掌

在平时的工作中，我们最常碰到的问题就是某某进程对应的网络状况如何？是不是连接被打爆了？还是有大量的 TIME_WAIT 连接？

netstat 可以帮助我们了解当前的网络连接状况，比如我想知道当前所有的连接详情，就可以使用下面这行命令：

netstat -alepn

可能的结果为：

netstat 会把所有 IPv4 形态的 TCP，IPV6 形态的 TCP、UDP 以及 UNIX 域的套接字都显示出来。

对于 TCP 类型来说，最大的好处是可以清楚地看到一条 TCP 连接的四元组（源地址、源端口、目的地地址和目的端口）。

例如这里的一条信息：

tcp        0      0 127.0.0.1:2379          127.0.0.1:52464         ESTABLISHED 0          27710       3496/etcd

它表达的意思是本地 127.0.0.1 的端口 52464 连上本地 127.0.0.1 的端口 2379，状态为 ESTABLISHED，本地进程为 etcd，进程为 3496。

这在实战分析的时候非常有用，比如你可以很方便地知道，在某个时候是不是有很多 TIME_WAIT 的 TCP 连接，导致端口号被占用光，以致新的连接分配不了。

当然，我们也可以只对 UNIX 套接字进行筛查。

netstat Socket -x -alepn

UNIX 套接字的结果稍有不同，最关键的信息是 Path，这个信息显示了本地套接字监听的文件路径，比如这条：

unix  3      [ ]         STREAM     CONNECTED     23209    1400/dockerd        /var/run/docker.sock

这其实就是大名鼎鼎的 Docker 在本地套接字的监听路径。/var/run/docker.sock 是本地套接字监听地址，dockerd 是进程名称，1400 是进程号。

netstat 命令可以选择的参数非常之多，这里只关注了几个简单的场景，你可以通过帮助命令或者查阅文档获得更多的信息。

lsof 的常见用途之一是帮助我们找出在指定的 IP 地址或者端口上打开套接字的进程，而 netstat 则告诉我们 IP 地址和端口使用的情况，以及各个 TCP 连接的状态。Isof 和 netstst 可以结合起来一起使用。

比如说，我们可以通过 lsof 查看到底是谁打开了这个文件：

lsof /var/run/docker.sock

下面这张图显示了是 dockerd 打开了这个本地文件套接字：

lsof 还有一个非常常见的用途。如果我们启动了一个服务器程序，发现这个服务器需要绑定的端口地址已经被占用，内核报出“该地址已在使用”的出错信息，我们可以使用 lsof 找出正在使用该端口的那个进程。比如下面这个代码，就帮我们找到了使用 8080 端口的那个进程，从而帮助我们定位问题。

lsof -i :8080

抓包利器: tcpdump

tcpdump 这样的抓包工具对于网络编程而言是非常有用的，特别是在一些”山重水复疑无路“的情形下，通过 tcpdump 这样的抓包工具，往往可以达到”柳岸花明又一村“的效果。

tcpdump 具有非常强大的过滤和匹配功能。

比如说指定网卡：

tcpdump -i eth0

再比如说指定来源：

tcpdump src host hostname

我们再来一个复杂一点的例子。这里抓的包是 TCP，且端口是 80，包来自 IP 地址为 192.168.1.25 的主机地址。

tcpdump 'tcp and port 80 and src host 192.168.1.25'

如果我们对 TCP 协议非常熟悉，还可以写出这样的 tcpdump 命令：

tcpdump 'tcp and port 80 and tcp[13:1]&2 != 0'

这里 tcp[13:1] 表示的是 TCP 头部开始处偏移为 13 的字节，如果这个值为 2，说明设置了 SYN 分节，当然，我们也可以设置成其他值来获取希望类型的分节。

tcpdump 在开启抓包的时候，会自动创建一个类型为 AF_PACKET 的网络套接口，并向系统内核注册。当网卡接收到一个网络报文之后，它会遍历系统中所有已经被注册的网络协议，包括其中已经注册了的 AF_PACKET 网络协议。系统内核接下来就会将网卡收到的报文发送给该协议的回调函数进行一次处理，回调函数可以把接收到的报文完完整整地复制一份，假装是自己接收到的报文，然后交给 tcpdump 程序，进行各种条件的过滤和判断，再对报文进行解析输出。

下面这张图显示的是 tcpdump 的输出格式：

首先我们看到的是时间戳，之后类似 192.168.33.11.41388 > 192.168.33.11.6443 这样的，显示的是源地址（192.168.33.11.41388）到目的地址（192.168.33.11.6443）；然后 Flags [ ] 是包的标志，[P] 表示是数据推送，比较常见的包格式如下：

[S]：SYN，表示开始连接

[.]：没有标记，一般是确认

[P]：PSH，表示数据推送

[F]：FIN，表示结束连接

[R] ：RST，表示重启连接

我们可以看到最后有几个数据，它们代表的含义如下：

seq：包序号，就是 TCP 的确认分组

cksum：校验码

win：滑动窗口大小

length：承载的数据（payload）长度 length，如果没有数据则为 0

此外，tcpdump 还可以对每条 TCP 报文的细节进行显示，让我们可以看到每条报文的详细字节信息。这在对报文进行排查的时候很有用。

小结

本章我讲述了一些常见的网络诊断工具，这些工具需要你了解之后活学活用。用好它们，对加深网络编程的理解，以及对问题情况进行排查等都有非常大的帮助。

我再来总结一下这几个命令的作用：

ping 可以用来帮助我们进行网络连通性的探测。

ifconfig，用来显示当前系统中的所有网络设备。

netstat 和 lsof 可以查看活动的连接状况。

tcpdump 可以对各种奇怪的环境进行抓包，进而帮我们了解报文，排查问题。

思考题

最后给大家留两个思考题。

本章我讲到了强大的抓包工具 tcpdump，你知道 tcpdump 这个工具还可以对 UDP 包进行抓包处理吗？你不妨尝试一下。

另外，netstat 输出时，监听状态的套接字所对应的 Foreign Address 显示的 *.* 表示的是什么意思呢？

欢迎你在评论区写下你的思考，我会和你一起交流，也欢迎你把这篇文章分享给你的朋友或者同事，一起讨论下这几个工具。

09丨答疑篇：学习网络编程前，需要准备哪些东西？

你好，我是盛延敏，这里是网络编程实战第 9 讲，欢迎回来。

今天是基础篇的最后一讲。在这一讲中，我将会针对基础篇中大家提出的普遍问题进行总结和答疑，让我们整理一下，再接着学习下一个模块的内容。

代码和环境

既然我希望通过学习，可以带你进行网络编程实战，那么就得有一个环境，可以运行文章中的例子，并加以活学活用。

我已经将代码上传到 GitHub 中，你可以访问以下地址来获得最新的代码。

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/， 获得 30 天的免费使用。

CLion 自带了 CMake 等工具，可以开箱即用。它最强大的地方，是可以直接设置断点，方便进行调试。我们进入主菜单，选择 Run，再选择 Debug，就可以启动程序，进行调试。

有些情况下，启动程序时需要输入一些参数，这个时候需要使用“Edit Configurations”为可执行程序配置参数。下面是一个例子：

学习路径

也许你刚刚入门，一直对网络编程的学习路径有很大的困惑，我在这里统一回复一下。

我觉得学习网络编程技术，必须要过一道语言关，这个语言就是 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/

UNIX 网络编程方面，强烈推荐 Stevens 大神的两卷本《UNIX 网络编程》，其中第一卷是讲套接字的，第二卷是讲 IPC 进程间通信的。这套书也随书配备了源代码，你如果有兴趣的话，可以对代码进行改写和调试。

豆瓣链接在此： https://book.douban.com/subject/1500149/

这套书的卷一基本上面面俱到地讲述了 UNIX 网络编程的方方面面，但有时候稍显啰嗦，特别是高性能高并发这块，已经跟不上时代，但你可以把注意力放在卷一的前半部分。

这套书翻译了好几版，就我的体验来说，比较推荐杨继张翻译的版本。

TCP/IP 协议方面，当然是推荐 Stevens 的大作《TCP/IP 详解》, 这套书总共有三卷，第一卷讲协议，第二卷讲实现，第三卷讲 TCP 事务。我在这里推荐第一卷，第二卷的实现是基于 BSD 的代码讲解的，就不推荐了。我想如果你想看源码的话，还是推荐看 Linux 的，毕竟我们用的比较多。第三卷涉及的内容比较少见，也不推荐了。

这套书各个出版社翻译了好多版本，你可以去豆瓣自行查看哪个版本评分比较高。

《TCP/IP 详解 卷 1：协议》豆瓣链接如下：

https://book.douban.com/subject/1088054/

最后除了书籍外，还有一个非常好的了解 TCP 的方法，那就是查看 RFC 文档，对于有一定英文能力的同学来说，可以说是一个捷径。RFC 最大的好处可以帮我们了解 TCP 发展的背景和脉络。

疑难解答

前面的内容算是我对你学习网络编程提供的一些小建议或者小帮助。接下来，我们正式进入到文章本身的内容。

在第 5 讲思考题部分中，我出了这么一道题目“一段数据流从应用程序发送端，一直到应用程序接收端，总共经过了多少次拷贝？”大家的回答五花八门。

我的本意可以用文稿中的一张图来表示，还记得 TCP/IP 层次模型么？我想通过这么一个问题，来展示 TCP/IP 分层的思想。

让我们先看发送端，当应用程序将数据送到发送缓冲区时，调用的是 send 或 write 方法，如果缓存中没有空间，系统调用就会失败或者阻塞。我们说，这个动作事实上是一次”显式拷贝“。而在这之后，数据将会按照 TCP/IP 的分层再次进行拷贝，这层的拷贝对我们来说就不是显式的了。

接下来轮到 TCP 协议栈工作，创建 Packet 报文，并把报文发送到传输队列中（qdisc），传输队列是一个典型的 FIFO 队列，队列的最大值可以通过 ifocnfig 命令输出的 txqueuelen 来查看。通常情况下，这个值有几千报文大小。

TX ring 在网络驱动和网卡之间，也是一个传输请求的队列。

网卡作为物理设备工作在物理层，主要工作是把要发送的报文保存到内部的缓存中，并发送出去。

接下来再看接收端，报文首先到达网卡，由网卡保存在自己的接收缓存中，接下来报文被发送至网络驱动和网卡之间的 RX ring，网络驱动从 RX ring 获取报文 ，然后把报文发送到上层。

这里值得注意的是，网络驱动和上层之间没有缓存，因为网络驱动使用 Napi 进行数据传输。因此，可以认为上层直接从 RX ring 中读取报文。

最后，报文的数据保存在套接字接收缓存中，应用程序从套接字接收缓存中读取数据。

这就是数据流从应用程序发送端，一直到应用程序接收端的整个历程，你看懂了吗？

上面的任何一个环节稍有积压，都会对程序性能产生影响。但好消息是，内核和网络设备供应商已经帮我们把一切都打点好了，我们看到和用到的，其实只是冰山上的一角而已。

这就是基础篇的总结与答疑部分，我先对之前基础篇的内容补充了一些资料，尽可能地为你学习网络编程提供方便，然后针对大家有明显疑惑的问题进行了解答，希望对你有所帮助。

10 | TIME_WAIT：隐藏在细节下的魔鬼

你好，我是盛延敏，这是网络编程实战的第 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 的四次挥手说起。我在文稿中放了这样一张图。

TCP 连接终止时，主机 1 先发送 FIN 报文，主机 2 进入 CLOSE_WAIT 状态，并发送一个 ACK 应答，同时，主机 2 通过 read 调用获得 EOF，并将此结果通知应用程序进行主动关闭操作，发送 FIN 报文。主机 1 在接收到 FIN 报文后发送 ACK 应答，此时主机 1 进入 TIME_WAIT 状态。

主机 1 在 TIME_WAIT 停留持续时间是固定的，是最长分节生命期 MSL（maximum segment lifetime）的两倍，一般称之为 2MSL。和大多数 BSD 派生的系统一样，Linux 系统里有一个硬编码的字段，名称为TCP_TIMEWAIT_LEN，其值为 60 秒。也就是说，Linux 系统停留在 TIME_WAIT 的时间为固定的 60 秒。

#define TCP_TIMEWAIT_LEN (60*HZ) /* how long to wait to destroy TIME-        WAIT state, about 60 seconds	*/

过了这个时间之后，主机 1 就进入 CLOSED 状态。为什么是这个时间呢？你可以先想一想，稍后我会给出解答。

你一定要记住一点，只有发起连接终止的一方会进入 TIME_WAIT 状态。这一点面试的时候经常会被问到。

TIME_WAIT 的作用

你可能会问，为什么不直接进入 CLOSED 状态，而要停留在 TIME_WAIT 这个状态？

这要从两个方面来说。

首先，这样做是为了确保最后的 ACK 能让被动关闭方接收，从而帮助其正常关闭。

TCP 在设计的时候，做了充分的容错性设计，比如，TCP 假设报文会出错，需要重传。在这里，如果图中主机 1 的 ACK 报文没有传输成功，那么主机 2 就会重新发送 FIN 报文。

如果主机 1 没有维护 TIME_WAIT 状态，而直接进入 CLOSED 状态，它就失去了当前状态的上下文，只能回复一个 RST 操作，从而导致被动关闭方出现错误。

现在主机 1 知道自己处于 TIME_WAIT 的状态，就可以在接收到 FIN 报文之后，重新发出一个 ACK 报文，使得主机 2 可以进入正常的 CLOSED 状态。

第二个理由和连接“化身”和报文迷走有关系，为了让旧连接的重复分节在网络中自然消失。

我们知道，在网络中，经常会发生报文经过一段时间才能到达目的地的情况，产生的原因是多种多样的，如路由器重启，链路突然出现故障等。如果迷走报文到达时，发现 TCP 连接四元组（源 IP，源端口，目的 IP，目的端口）所代表的连接不复存在，那么很简单，这个报文自然丢弃。

我们考虑这样一个场景，在原连接中断后，又重新创建了一个原连接的“化身”，说是化身其实是因为这个连接和原先的连接四元组完全相同，如果迷失报文经过一段时间也到达，那么这个报文会被误认为是连接“化身”的一个 TCP 分节，这样就会对 TCP 通信产生影响。

所以，TCP 就设计出了这么一个机制，经过 2MSL 这个时间，足以让两个方向上的分组都被丢弃，使得原来连接的分组在网络中都自然消失，再出现的分组一定都是新化身所产生的。

划重点，2MSL 的时间是从主机 1 接收到 FIN 后发送 ACK 开始计时的；如果在 TIME_WAIT 时间内，因为主机 1 的 ACK 没有传输到主机 2，主机 1 又接收到了主机 2 重发的 FIN 报文，那么 2MSL 时间将重新计时。道理很简单，因为 2MSL 的时间，目的是为了让旧连接的所有报文都能自然消亡，现在主机 1 重新发送了 ACK 报文，自然需要重新计时，以便防止这个 ACK 报文对新可能的连接化身造成干扰。

TIME_WAIT 的危害

过多的 TIME_WAIT 的主要危害有两种。

第一是内存资源占用，这个目前看来不是太严重，基本可以忽略。

第二是对端口资源的占用，一个 TCP 连接至少消耗一个本地端口。要知道，端口资源也是有限的，一般可以开启的端口为 32768～61000 ，也可以通过net.ipv4.ip_local_port_range指定，如果 TIME_WAIT 状态过多，会导致无法创建新连接。这个也是我们在一开始讲到的那个例子。

如何优化 TIME_WAIT？

在高并发的情况下，如果我们想对 TIME_WAIT 做一些优化，来解决我们一开始提到的例子，该如何办呢？

net.ipv4.tcp_max_tw_buckets

一个暴力的方法是通过 sysctl 命令，将系统值调小。这个值默认为 18000，当系统中处于 TIME_WAIT 的连接一旦超过这个值时，系统就会将所有的 TIME_WAIT 连接状态重置，并且只打印出警告信息。这个方法过于暴力，而且治标不治本，带来的问题远比解决的问题多，不推荐使用。

调低 TCP_TIMEWAIT_LEN，重新编译系统

这个方法是一个不错的方法，缺点是需要“一点”内核方面的知识，能够重新编译内核。我想这个不是大多数人能接受的方式。

SO_LINGER 的设置

英文单词“linger”的意思为停留，我们可以通过设置套接字选项，来设置调用 close 或者 shutdown 关闭连接时的行为。

int setsockopt(int sockfd, int level, int optname, const void *optval,

　　　　　　　　socklen_t optlen);

struct linger {

　int　 l_onoff;　　　　/* 0=off, nonzero=on */

　int　 l_linger;　　　　/* linger time, POSIX specifies units as seconds */

}

设置 linger 参数有几种可能：

如果l_onoff为 0，那么关闭本选项。l_linger的值被忽略，这对应了默认行为，close 或 shutdown 立即返回。如果在套接字发送缓冲区中有数据残留，系统会将试着把这些数据发送出去。

如果l_onoff为非 0， 且l_linger值也为 0，那么调用 close 后，会立该发送一个 RST 标志给对端，该 TCP 连接将跳过四次挥手，也就跳过了 TIME_WAIT 状态，直接关闭。这种关闭的方式称为“强行关闭”。 在这种情况下，排队数据不会被发送，被动关闭方也不知道对端已经彻底断开。只有当被动关闭方正阻塞在recv()调用上时，接受到 RST 时，会立刻得到一个“connet reset by peer”的异常。

struct linger so_linger;

so_linger.l_onoff = 1;

so_linger.l_linger = 0;

setsockopt(s,SOL_SOCKET,SO_LINGER, &so_linger,sizeof(so_linger));

如果l_onoff为非 0， 且l_linger的值也非 0，那么调用 close 后，调用 close 的线程就将阻塞，直到数据被发送出去，或者设置的l_linger计时时间到。

第二种可能为跨越 TIME_WAIT 状态提供了一个可能，不过是一个非常危险的行为，不值得提倡。

net.ipv4.tcp_tw_reuse：更安全的设置

那么 Linux 有没有提供更安全的选择呢？

当然有。这就是net.ipv4.tcp_tw_reuse选项。

Linux 系统对于net.ipv4.tcp_tw_reuse的解释如下:

Allow to reuse TIME-WAIT sockets for new connections when it is safe from protocol viewpoint. Default value is 0.It should not be changed without advice/request of technical experts.

这段话的大意是从协议角度理解如果是安全可控的，可以复用处于 TIME_WAIT 的套接字为新的连接所用。

那么什么是协议角度理解的安全可控呢？主要有两点：

只适用于连接发起方（C/S 模型中的客户端）；

对应的 TIME_WAIT 状态的连接创建时间超过 1 秒才可以被复用。

使用这个选项，还有一个前提，需要打开对 TCP 时间戳的支持，即net.ipv4.tcp_timestamps=1（默认即为 1）。

要知道，TCP 协议也在与时俱进，RFC 1323 中实现了 TCP 拓展规范，以便保证 TCP 的高可用，并引入了新的 TCP 选项，两个 4 字节的时间戳字段，用于记录 TCP 发送方的当前时间戳和从对端接收到的最新时间戳。由于引入了时间戳，我们在前面提到的 2MSL 问题就不复存在了，因为重复的数据包会因为时间戳过期被自然丢弃。

总结

在今天的内容里，我讲了 TCP 的四次挥手，重点对 TIME_WAIT 的产生、作用以及优化进行了讲解，你需要记住以下三点：

TIME_WAIT 的引入是为了让 TCP 报文得以自然消失，同时为了让被动关闭方能够正常关闭；

不要试图使用SO_LINGER设置套接字选项，跳过 TIME_WAIT；

现代 Linux 系统引入了更安全可控的方案，可以帮助我们尽可能地复用 TIME_WAIT 状态的连接。

思考题

最后按照惯例，我留两道思考题，供你消化今天的内容。

最大分组 MSL 是 TCP 分组在网络中存活的最长时间，你知道这个最长时间是如何达成的？换句话说，是怎么样的机制，可以保证在 MSL 达到之后，报文就自然消亡了呢？

RFC 1323 引入了 TCP 时间戳，那么这需要在发送方和接收方之间定义一个统一的时钟吗？

欢迎你在评论区写下你的思考，如果通过这篇文章你理解了 TIME_WAIT，欢迎你把这篇文章分享给你的朋友或者同事，一起交流学习一下。

11 | 优雅地关闭还是粗暴地关闭 ?

你好，我是盛延敏，这里是网络编程实战第 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

Hi,data1

close

$./graceserver

received 5 bytes: data1

send bytes: 9

received 5 bytes: data2

send bytes: 9

client closed

客户端依次发送了 data1 和 data2，服务器端也正常接收到 data1 和 data2。在客户端 close 掉整个连接之后，服务器端接收到 SIGPIPE 信号，直接退出。客户端并没有收到服务器端的应答数据。

我在下面放了一张图，这张图详细解释了客户端和服务器端交互的时序图。因为客户端调用 close 函数关闭了整个连接，当服务器端发送的“Hi, data1”分组到底时，客户端给回送一个 RST 分组；服务器端再次尝试发送“Hi, data2”第二个应答分组时，系统内核通知 SIGPIPE 信号。这是因为，在 RST 的套接字进行写操作，会直接触发 SIGPIPE 信号。

这回知道你的程序莫名奇妙终止的原因了吧。

我们可以像这样注册一个信号处理函数，对 SIGPIPE 信号进行处理，避免程序莫名退出：

static void sig_pipe(int signo) {

    printf("\nreceived %d datagrams\n", count);

    exit(0);

}

signal(SIGINT, sig_pipe);

接下来，再次启动服务器，再启动客户端，依次在标准输入上输入 data1、data2 和 shutdown 函数，观察一段时间后我们看到：

$./graceclient 127.0.0.1

data1

now sending data1

send bytes:5

data2

now sending data2

send bytes:5

shutdown

Hi, data1

Hi，data2

server terminated

$./graceserver

received 5 bytes: data1

send bytes: 9

received 5 bytes: data2

send bytes: 9

client closed

和前面的结果不同，服务器端输出了 data1、data2；客户端也输出了“Hi,data1”和“Hi,data2”，客户端和服务器端各自完成了自己的工作后，正常退出。

我们再看下客户端和服务器端交互的时序图。因为客户端调用 shutdown 函数只是关闭连接的一个方向，服务器端到客户端的这个方向还可以继续进行数据的发送和接收，所以“Hi,data1”和“Hi,data2”都可以正常传送；当服务器端读到 EOF 时，立即向客户端发送了 FIN 报文，客户端在 read 函数中感知了 EOF，也进行了正常退出。

总结

在这一讲中，我们讲述了 close 函数关闭连接的方法，使用 close 函数关闭连接有两个需要明确的地方。

close 函数只是把套接字引用计数减 1，未必会立即关闭连接；

close 函数如果在套接字引用计数达到 0 时，立即终止读和写两个方向的数据传送。

基于这两个确定，在期望关闭连接其中一个方向时，应该使用 shutdown 函数。

思考题

和往常一样，给大家留两道思考题。

第一道题，你可以看到在今天的服务器端程序中，直接调用exit(0)完成了 FIN 报文的发送，这是为什么呢？为什么不调用 close 函数或 shutdown 函数呢？

第二道题关于关于信号量处理，今天的程序中，使用的是SIG_IGN默认处理，你知道默认处理和自定义函数处理的区别吗？不妨查查资料，了解一下。

欢迎你在评论区写下你的思考，也欢迎把这篇文章分享给你的朋友或者同事，一起来交流。

12 | 连接无效：使用Keep-Alive还是应用心跳来检测？

你好，我是盛延敏，这里是网络编程实战第 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 没有提供系统的保活能力，让应用程序可以方便地感知连接的存活，但是，我们可以在应用程序里灵活地建立这种机制。一般来说，这种机制的建立依赖于系统定时器，以及恰当的应用层报文协议。

思考题

和往常一样，我留两道思考题给大家：

你可以看到今天的内容主要是针对 TCP 的探活，那么你觉得这样的方法是否同样适用于 UDP 呢？

第二道题是，有人说额外的探活报文占用了有限的带宽，对此你是怎么想的呢？而且，为什么需要多次探活才能决定一个 TCP 连接是否已经死亡呢？

欢迎你在评论区写下你的思考，我会和你一起交流。也欢迎把这篇文章分享给你的朋友或者同事，与他们一起讨论一下这两个问题吧。

13 | 小数据包应对之策：理解TCP协议中的动态数据传输

你好，我是盛延敏，这里是网络编程实战第 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 算法解除请求第二部分的阻止，让第二部分得以发送出去，服务器端在收到之后，进行处理应答，同时将第二部分的确认捎带发送出去。

你从这张图中可以看到，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 拥塞控制算法是一个非常重要的研究领域，请你查阅下最新的有关这方面的研究，看看有没有新的发现？

欢迎你在评论区写下你的思考，也欢迎把这篇文章分享给你的朋友或者同事，一起来交流。

14丨UDP也可以是“已连接”？

你好，我是盛延敏，这里是网络编程实战的第 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，绑定本地地址和端口，是为了让我们的程序可以快速获取异步错误信息的通知，同时也可以获得一定性能上的提升。

思考题

在本讲的最后，按照惯例，给大家留两个思考题：

可以对一个 UDP 套接字进行多次 connect 操作吗? 你不妨动手试试，看看结果。

如果想使用多播或广播，我们应该怎么去使用 connect 呢？

欢迎你在评论区写下你的思考，也欢迎把这篇文章分享给你的朋友或者同事，一起交流一下。

15 | 怎么老是出现“地址已经被使用”？

你好，我是盛延敏，这里是网络编程实战的第 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。如果你对此有点淡忘，没有关系，我在下面放了一张图，希望会唤起你的记忆。

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

通过服务器端发起的关闭连接操作，引起了一个已有的 TCP 连接处于 TME_WAIT 状态，正是这个 TIME_WAIT 的连接，使得服务器重启时，继续绑定在 127.0.0.1 地址和 9527 端口上的操作，返回了Address already in use的错误。

重用套接字选项

我们知道，一个 TCP 连接是通过四元组（源地址、源端口、目的地址、目的端口）来唯一确定的，如果每次 Telnet 客户端使用的本地端口都不同，就不会和已有的四元组冲突，也就不会有 TIME_WAIT 的新旧连接化身冲突的问题。

事实上，即使在很小的概率下，客户端 Telnet 使用了相同的端口，从而造成了新连接和旧连接的四元组相同，在现代 Linux 操作系统下，也不会有什么大的问题，原因是现代 Linux 操作系统对此进行了一些优化。

第一种优化是新连接 SYN 告知的初始序列号，一定比 TIME_WAIT 老连接的末序列号大，这样通过序列号就可以区别出新老连接。

第二种优化是开启了 tcp_timestamps，使得新连接的时间戳比老连接的时间戳大，这样通过时间戳也可以区别出新老连接。

在这样的优化之下，一个 TIME_WAIT 的 TCP 连接可以忽略掉旧连接，重新被新的连接所使用。

这就是重用套接字选项，通过给套接字配置可重用属性，告诉操作系统内核，这样的 TCP 连接完全可以复用 TIME_WAIT 状态的连接。代码片段已经放在文章中了：

int on = 1;

setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));

SO_REUSEADDR 套接字选项，允许启动绑定在一个端口，即使之前存在一个和该端口一样的连接。前面的例子已经表明，在默认情况下，服务器端历经创建 socket、bind 和 listen 重启时，如果试图绑定到一个现有连接上的端口，bind 操作会失败，但是如果我们在创建 socket 和 bind 之间，使用上面的代码片段设置 SO_REUSEADDR 套接字选项，情况就会不同。

下面我们对原来的服务器端代码进行升级，升级的部分主要在 11-12 行，在 bind 监听套接字之前，调用 setsockopt 方法，设置重用套接字选项：

nt main(int argc, char **argv) {

    int listenfd;

    listenfd = socket(AF_INET, SOCK_STREAM, 0);

    struct sockaddr_in server_addr;

    bzero(&server_addr, sizeof(server_addr));

    server_addr.sin_family = AF_INET;

    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);

    server_addr.sin_port = htons(SERV_PORT);

    int on = 1;

    setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));

    int rt1 = bind(listenfd, (struct sockaddr *) &server_addr, sizeof(server_addr));

    if (rt1 < 0) {

        error(1, errno, "bind failed ");

    }

    int rt2 = listen(listenfd, LISTENQ);

    if (rt2 < 0) {

        error(1, errno, "listen failed ");

    }

    signal(SIGPIPE, SIG_IGN);

    int connfd;

    struct sockaddr_in client_addr;

    socklen_t client_len = sizeof(client_addr);

    if ((connfd = accept(listenfd, (struct sockaddr *) &client_addr, &client_len)) < 0) {

        error(1, errno, "bind failed ");

    }

    char message[MAXLINE];

    count = 0;

    for (;;) {

        int n = read(connfd, message, MAXLINE);

        if (n < 0) {

            error(1, errno, "error read");

        } else if (n == 0) {

            error(1, 0, "client closed \n");

        }

        message[n] = 0;

        printf("received %d bytes: %s\n", n, message);

        count++;

    }

}

重新编译过后，重复上面那个例子，先启动服务器，再使用 Telnet 作为客户端登录到服务器，在屏幕上输入一些字符，使用 Ctrl+C 的方式在服务器端关闭连接。马上尝试重启服务器，这个时候我们发现，服务器正常启动，没有出现Address already in use的错误。这说明我们的修改已经起作用。

 $./addressused2

 received 9 bytes: network

 received 6 bytes: good

 client closed

 $./addressused2

SO_REUSEADDR 套接字选项还有一个作用，那就是本机服务器如果有多个地址，可以在不同地址上使用相同的端口提供服务。

比如，一台服务器有 192.168.1.101 和 10.10.2.102 连个地址，我们可以在这台机器上启动三个不同的 HTTP 服务，第一个以本地通配地址 ANY 和端口 80 启动；第二个以 192.168.101 和端口 80 启动；第三个以 10.10.2.102 和端口 80 启动。

这样目的地址为 192.168.101，目的端口为 80 的连接请求会被发往第二个服务；目的地址为 10.10.2.102，目的端口为 80 的连接请求会被发往第三个服务；目的端口为 80 的所有其他连接请求被发往第一个服务。

我们必须给这三个服务设置 SO_REUSEADDR 套接字选项，否则第二个和第三个服务调用 bind 绑定到 80 端口时会出错。

最佳实践

这里的最佳实践可以总结成一句话： 服务器端程序，都应该设置 SO_REUSEADDR 套接字选项，以便服务端程序可以在极短时间内复用同一个端口启动。

有些人可能觉得这不是安全的。其实，单独重用一个套接字不会有任何问题。我在前面已经讲过，TCP 连接是通过四元组唯一区分的，只要客户端不使用相同的源端口，连接服务器是没有问题的，即使使用了相同的端口，根据序列号或者时间戳，也是可以区分出新旧连接的。

而且，TCP 的机制绝对不允许在相同的地址和端口上绑定不同的服务器，即使我们设置 SO_REUSEADDR 套接字选项，也不可能在 ANY 通配符地址下和端口 9527 上重复启动两个服务器实例。如果我们启动第二个服务器实例，不出所料会得到Address already in use的报错，即使当前还没有任何一条有效 TCP 连接产生。

比如下面就是第二次运行服务器端程序的报错信息：

 $./addressused2

 bind faied: Address already in use(98)

你可能还记得第 10 讲中，我们提到过一个叫做 tcp_tw_reuse 的内核配置选项，这里又提到了 SO_REUSEADDR 套接字选择，你会不会觉得两个有点混淆呢？

其实，这两个东西一点关系也没有。

tcp_tw_reuse 是内核选项，主要用在连接的发起方。TIME_WAIT 状态的连接创建时间超过 1 秒后，新的连接才可以被复用，注意，这里是连接的发起方；

SO_REUSEADDR 是用户态的选项，SO_REUSEADDR 选项用来告诉操作系统内核，如果端口已被占用，但是 TCP 连接状态位于 TIME_WAIT ，可以重用端口。如果端口忙，而 TCP 处于其他状态，重用端口时依旧得到“Address already in use”的错误信息。注意，这里一般都是连接的服务方。

总结

今天我们分析了“Address already in use”产生的原因和解决方法。你只要记住一句话，在所有 TCP 服务器程序中，调用 bind 之前请设置 SO_REUSEADDR 套接字选项。这不会产生危害，相反，它会帮助我们在很快时间内重启服务端程序，而这一点恰恰是很多场景所需要的。

思考题

跟往常一样，给大家布置两道思考题：

第一道，之前我们看到的例子，都是对 TCP 套接字设置 SO_REUSEADDR 套接字选项，你知道吗，我们也可以对 UDP 设置 SO_REUSEADDR 套接字选项。那么问题来了，对 UDP 来说，设置 SO_REUSEADDR 套接字选项有哪些场景和好处呢？

第二道，在服务器端程序中，设置 SO_REUSEADDR 套接字选项时，需要在 bind 函数之前对监听字进行设置，想一想，为什么不是对已连接的套接字进行设置呢？

欢迎你在评论区写下你的思考，我会和你一起讨论交流，也欢迎把这篇文章分享给你的朋友或者同事，一起交流一下。

16 | 如何理解TCP的“流”？

你好，我是盛延敏，这里是网络编程实战第 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，对应的二进制为 00000010000000001，那么两个字节的数据到底是先传 0x01，还是相反？

在计算机发展的历史上，对于如何存储这个数据没有形成标准。比如这里讲到的问题，不同的系统就会有两种存法，一种是将 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)

报文读取和解析

应该看到，报文是以字节流的形式呈现给应用程序的，那么随之而来的一个问题就是，应用程序如何解读字节流呢？

这就要说到报文格式和解析了。报文格式实际上定义了字节的组织形式，发送端和接收端都按照统一的报文格式进行数据传输和解析，这样就可以保证彼此能够完成交流。

只有知道了报文格式，接收端才能针对性地进行报文读取和解析工作。

报文格式最重要的是如何确定报文的边界。常见的报文格式有两种方法，一种是发送端把要发送的报文长度预先通过报文告知给接收端；另一种是通过一些特殊的字符来进行边界的划分。

显式编码报文长度

报文格式

下面我们来看一个例子，这个例子是把要发送的报文长度预先通过报文告知接收端，你可以看到文章中有这样一张图。

由图可以看出，这个报文的格式很简单，首先 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 是一个非常好的例子。

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 分组的报文格式，有什么区别和联系吗？

欢迎你在评论区写下你的思考，也欢迎把这篇文章分享给你的朋友或者同事，与他们一起交流一下这两个问题吧。

17 | TCP并不总是“可靠”的？

你好，我是盛延敏，这里是网络编程实战第 17 讲，欢迎回来。

在前面一讲中，我们讲到如何理解 TCP 数据流的本质，进而引出了报文格式和解析。在这一讲里，我们讨论通过如何增强读写操作，以处理各种“不可靠”的场景。

TCP 是可靠的？

你可能会认为，TCP 是一种可靠的协议，这种可靠体现在端到端的通信上。这似乎给我们带来了一种错觉，从发送端来看，应用程序通过调用 send 函数发送的数据流总能可靠地到达接收端；而从接收端来看，总是可以把对端发送的数据流完整无损地传递给应用程序来处理。

事实上，如果我们对 TCP 传输环节进行详细的分析，你就会沮丧地发现，上述论断是不正确的。

前面我们已经了解，发送端通过调用 send 函数之后，数据流并没有马上通过网络传输出去，而是存储在套接字的发送缓冲区中，由网络协议栈决定何时发送、如何发送。当对应的数据发送给接收端，接收端回应 ACK，存储在发送缓冲区的这部分数据就可以删除了，但是，发送端并无法获取对应数据流的 ACK 情况，也就是说，发送端没有办法判断对端的接收方是否已经接收发送的数据流，如果需要知道这部分信息，就必须在应用层自己添加处理逻辑，例如显式的报文确认机制。

从接收端来说，也没有办法保证 ACK 过的数据部分可以被应用程序处理，因为数据需要接收端程序从接收缓冲区中拷贝，可能出现的状况是，已经 ACK 的数据保存在接收端缓冲区中，接收端处理程序突然崩溃了，这部分数据就没有办法被应用程序继续处理。

你有没有发现，TCP 协议实现并没有提供给上层应用程序过多的异常处理细节，或者说，TCP 协议反映链路异常的能力偏弱，这其实是有原因的。要知道，TCP 诞生之初，就是为了美国国防部服务的，考虑到军事作战的实际需要，TCP 不希望暴露更多的异常细节，而是能够以无人值守、自我恢复的方式运作。

TCP 连接建立之后，能感知 TCP 链路的方式是有限的，一种是以 read 为核心的读操作，另一种是以 write 为核心的写操作。接下来，我们就看下如何通过读写操作来感知异常情况，以及对应的处理方式。

故障模式总结

在实际情景中，我们会碰到各种异常的情况。在这里我把这几种异常情况归结为两大类：

第一类，是对端无 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 Resest）。

如果是一次 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 包，于是正常退出了客户端程序。

注意如果我们的速度不够快，导致服务器端从睡眠中苏醒，并成功将报文发送出来后，客户端会正常显示，此时我们停留，等待标准输入。如果不继续通过 read 或 write 操作对套接字进行读写，是无法感知服务器端已经关闭套接字这个事实的。

通过 write 产生 RST，read 调用感知 RST

这一次，我们仍然依次启动服务器端和客户端程序，在客户端输入 bad 字符之后，等待一段时间，直到客户端正确显示了服务端的回应“bad”字符之后，再杀死服务器程序。客户端再次输入 bad2，这时屏幕上打印出”peer connection closed“。

我在文稿中给出了这个案例的屏幕输出和时序图。

$./reliable_client01 127.0.0.1

$bad

$bad

$bad2

$peer connection closed

在很多书籍和文章中，对这个程序的解读是，收到 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 错误信息。

你可以看到针对这个全过程的一张描述图，你可以参考这张图好好理解一下这个过程。

以上是在 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 系统中重新模拟一下今天文章里的实验，看看运行结果是否和我的一样。欢迎你把内核版本和结果贴在评论里。

第二道题是，如果服务器主机正常关闭，已连接的程序会发生什么呢？

你不妨思考一下这两道题，欢迎你在评论区写下你的模拟结果和思考，我会和你一起交流，也欢迎把这篇文章分享给你的朋友或者同事，一起交流一下。

18 | 防人之心不可无：检查数据的有效性

你好，我是盛延敏，这里是网络编程实战第 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 函数来分配缓冲区？

欢迎你在评论区写下你的思考，也欢迎把这篇文章分享给你的朋友或者同事，一起交流一下。

19丨提高篇答疑：如何理解TCP四次挥手？

你好，我是盛延敏，这里是网络编程实战第 19 讲，欢迎回来。

这一篇文章是提高篇的答疑部分，也是提高篇的最后一篇文章。非常感谢大家的积极评论与留言，让每一篇文章的留言区都成为学习互动的好地方。在今天的内容里，我将针对大家的问题做一次集中回答，希望能帮助你解决前面碰到的一些问题。

这部分，我将采用 Q&A 的形式来展开。

如何理解 TCP 四次握手？

TCP 建立一个连接需 3 次握手，而终止一个连接则需要四次挥手。四次挥手的整个过程是这样的：

首先，一方应用程序调用 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 讲中，我们讲了关闭连接的几种方式，有同学对这一篇文章中的程序和时序图存在疑惑，并提出了下面几个问题：

代码运行结果是先显示 hi data1，之后才接收到标准输入的 close，为什么时序图中画的是先 close 才接收到 hi data1？

当一方主动 close 之后，另一方发送数据的时候收到 RST。主动方缓冲区会把这个数据丢弃吗？这样的话，应用层应该读不到了吧？

代码中 SIGPIPE 的作用不是忽略吗？为什么服务器端会退出？

主动调用 socket 的那方关闭了写端，但是还没关闭读端，这时候 socket 再读到数据是不是就是 RST？然后再 SIGPIPE？如果是这样的话，为什么不一次性把读写全部关闭呢？

我还是再仔细讲一下这个程序和时序图。

首先回答问题 1。针对 close 这个例子，时序图里画的 close 表示的是客户端发起的 close 调用。

关于问题 2，“Hi, data1”确实是不应该被接收到的，这个数据报即使发送出去也会收到 RST 回执，应用层是读不到的，我已经在文稿中修改。

关于问题 3 中 SIGPIPE 的作用，事实上，默认的 SIGPIPE 忽略行为就是退出程序，什么也不做，当然，实际程序还是要做一些清理工作的。

问题 4 的理解是错误的。第二个例子也显示了，如果主动关闭的一方调用 shutdown 关闭，没有关闭读这一端，主动关闭的一方可以读到对端的数据，注意这个时候主动关闭连接的一方是在使用 read 方法进行读操作，而不是 write 写操作，不会有 RST 的发生，更不会有 SIGPIPE 的发生。

总结

以上就是提高篇中一些同学的疑问。我们常说，学问学问，有学才有问。我希望通过今天的答疑可以让你加深对文章的理解，为后面的模块做准备。

这篇文章之后，我们就将进入到专栏中最重要的部分，也就是性能篇和实战篇了，在性能篇和实战篇里，我们将会使用到之前学到的知识，逐渐打造一个高性能的网络程序框架，你，准备好了吗？

如果你觉得今天的答疑内容对你有所帮助，欢迎把它转发给你的朋友或者同事，一起交流一下。

期中大作业丨动手编写一个自己的程序吧！

你好，我们之前已经学习了网络编程的基础篇和提高篇。经过近两个月的学习，不知道你对这些内容的掌握程度如何呢？

我之前说过，网络编程是一个既重视理论，又重视实战的内容模块。一味地消化理论并不足以让你掌握网络编程，只有自己亲自动手写代码，编写程序，才能对 TCP、UDP、套接字这些内容有更深切的体会，才能切实感受到它们是如何帮助我们的程序进行互联互通的。

网络编程就像一个魔法棒，我们之前已经学习了一些“咒语”，但上手操纵才能真实地施展魔法。所以我在专栏中安排了一个期中作业，借由这个作业让你上手编写代码，相信你在这个过程中也会更有成就感。

我在这里再提供一些“咒语”提示，方便你回顾之前的内容，以便在解题的时候更加胸有成竹。

客户端程序可以以第 11 篇文章的程序例子为原型，这里主要考察使用 select 多路复用，一方面从标准输入接收字节流，另一方面通过套接字读写，以及使用 shutdown 关闭半连接的能力。

服务器端程序则考察套接字读写的能力，以及对端连接关闭情况下的异常处理等能力。

题目不难，相信你可以做好。

题干

请你分别写一个客户端程序和服务器程序，客户端程序连接上服务器之后，通过敲命令和服务器进行交互，支持的交互命令包括：

pwd：显示服务器应用程序启动时的当前路径。

cd：改变服务器应用程序的当前路径。

ls：显示服务器应用程序当前路径下的文件列表。

quit：客户端进程退出，但是服务器端不能退出，第二个客户可以再次连接上服务器端。

客户端程序要求

可以指定待连接的服务器端 IP 地址和端口。

在输入一个命令之后，回车结束，之后等待服务器端将执行结果返回，客户端程序需要将结果显示在屏幕上。

样例输出如下所示。

第一次连接服务器

$./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

服务器程序要求

暂时不需要考虑多个客户并发连接的情形，只考虑每次服务一个客户连接。

要把命令执行的结果返回给已连接的客户端。

服务器端不能因为客户端退出就直接退出。

你可以把自己编写的程序代码放到 GitHub 上，并在评论里留下链接。我会认真查看这些代码，并在周五给出自己的反馈意见以及题目分析。由于时间有限，无法尽数查看，后续我会以答疑或者加餐的形式再做补充。

期待你的成果！

期中大作业丨题目以及解答剖析

你好，今天是期中大作业讲解课。诚如一位同学所言，这次的大作业不是在考察网络编程的细节，而是在考如何使用系统 API 完成 cd、pwd、ls 等功能。不过呢，网络编程的框架总归还是要掌握的。

我研读了大部分同学的代码，基本上是做得不错的，美中不足的是能动手完成代码编写和调试的同学偏少。我还是秉持一贯的看法，计算机程序设计是一门实战性很强的学科，如果只是单纯地听讲解，没有自己动手这一环，对知识的掌握总归还是差那么点意思。

代码我已经 push 到这里，你可以点进链接看一下。

客户端程序

废话少说，我贴下我的客户端程序：

#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 服务器。挑战和难度越来越高，你准备好了吗?

20 | 大名⿍⿍的select：看我如何同时感知多个I/O事件

你好，我是盛延敏，这里是网络编程实战的第 20 讲，欢迎回来。

这一讲是性能篇的第一讲。在性能篇里，我们将把注意力放到如何设计高并发高性能的网络服务器程序上。我希望通过这一模块的学习，让你能够掌握多路复用、异步 I/O、多线程等知识，从而可以写出支持并发 10K 以上的高性能网络服务器程序。

还等什么呢？让我们开始吧。

什么是 I/O 多路复用

在第 11 讲中，我们设计了这样一个应用程序，该程序从标准输入接收数据输入，然后通过套接字发送出去，同时，该程序也通过套接字接收对方发送的数据流。

我们可以使用 fgets 方法等待标准输入，但是一旦这样做，就没有办法在套接字有数据的时候读出数据；我们也可以使用 read 方法等待套接字有数据返回，但是这样做，也没有办法在标准输入有数据的情况下，读入数据并发送给对方。

I/O 多路复用的设计初衷就是解决这样的场景。我们可以把标准输入、套接字等都看做 I/O 的一路，多路复用的意思，就是在任何一路 I/O 有“事件”发生的情况下，通知应用程序去处理相应的 I/O 事件，这样我们的程序就变成了“多面手”，在同一时刻仿佛可以处理多个 I/O 事件。

像刚才的例子，使用 I/O 复用以后，如果标准输入有数据，立即从标准输入读入数据，通过套接字发送出去；如果套接字有数据可以读，立即可以读出数据。

select 函数就是这样一种常见的 I/O 多路复用技术，我们将在后面继续讲解其他的多路复用技术。使用 select 函数，通知内核挂起进程，当一个或多个 I/O 事件发生后，控制权返还给应用程序，由应用程序进行 I/O 事件的处理。

这些 I/O 事件的类型非常多，比如：

标准输入文件描述符准备好可以读。

监听套接字准备好，新的连接已经建立成功。

已连接套接字准备好可以写。

如果一个 I/O 事件等待超过了 10 秒，发生了超时事件。

select 函数的使用方法

select 函数的使用方法有点复杂，我们先看一下它的声明：

int select(int maxfd, fd_set *readset, fd_set *writeset, fd_set *exceptset, const struct timeval *timeout);

返回：若有就绪描述符则为其数目，若超时则为 0，若出错则为 -1

在这个函数中，n 表示的是待测试的描述符基数，它的值是待测试的最大描述符加 1。比如现在的 select 待测试的描述符集合是{0,1,4}，那么 maxfd 就是 5，为啥是 5，而不是 4 呢? 我会在下面进行解释。

紧接着的是三个描述符集合，分别是读描述符集合 readset、写描述符集合 writeset 和异常描述符集合 exceptset，这三个分别通知内核，在哪些描述符上检测数据可以读，可以写和有异常发生。

那么如何设置这些描述符集合呢？以下的宏可以帮助到我们。

void FD_ZERO(fd_set *fdset);

void FD_SET(int fd, fd_set *fdset);

void FD_CLR(int fd, fd_set *fdset);

int  FD_ISSET(int fd, fd_set *fdset);

如果你刚刚入门，理解这些宏可能有些困难。没有关系，我们可以这样想象，下面一个向量代表了一个描述符集合，其中，这个向量的每个元素都是二机制数中的 0 或者 1。

a[maxfd-1], ..., a[1], a[0]

我们按照这样的思路来理解这些宏：

FD_ZERO 用来将这个向量的所有元素都设置成 0；

FD_SET 用来把对应套接字 fd 的元素，a[fd] 设置成 1；

FD_CLR 用来把对应套接字 fd 的元素，a[fd] 设置成 0；

FD_ISSET 对这个向量进行检测，判断出对应套接字的元素 a[fd] 是 0 还是 1。

其中 0 代表不需要处理，1 代表需要处理。

怎么样，是不是感觉豁然开朗了？

实际上，很多系统是用一个整型数组来表示一个描述字集合的，一个 32 位的整型数可以表示 32 个描述字，例如第一个整型数表示 0-31 描述字，第二个整型数可以表示 32-63 描述字，以此类推。

这个时候再来理解为什么描述字集合{0,1,4}，对应的 maxfd 是 5，而不是 4，就比较方便了。

因为这个向量对应的是下面这样的：

a[4],a[3],a[2],a[1],a[0]

待测试的描述符个数显然是 5， 而不是 4。

三个描述符集合中的每一个都可以设置成空，这样就表示不需要内核进行相关的检测。

最后一个参数是 timeval 结构体时间：

struct timeval {

  long   tv_sec; /* seconds */

  long   tv_usec; /* microseconds */

};

这个参数设置成不同的值，会有不同的可能：

第一个可能是设置成空 (NULL)，表示如果没有 I/O 事件发生，则 select 一直等待下去。

第二个可能是设置一个非零的值，这个表示等待固定的一段时间后从 select 阻塞调用中返回，这在第 12 讲超时的例子里曾经使用过。

第三个可能是将 tv_sec 和 tv_usec 都设置成 0，表示根本不等待，检测完毕立即返回。这种情况使用得比较少。

程序例子

下面是一个具体的程序例子，我们通过这个例子来理解 select 函数。

int main(int argc, char **argv) {

    if (argc != 2) {

        error(1, 0, "usage: select01 <IPaddress>");

    }

    int socket_fd = tcp_client(argv[1], SERV_PORT);

    char recv_line[MAXLINE], send_line[MAXLINE];

    int n;

    fd_set readmask;

    fd_set allreads;

    FD_ZERO(&allreads);

    FD_SET(0, &allreads);

    FD_SET(socket_fd, &allreads);

    for (;;) {

        readmask = allreads;

        int rc = select(socket_fd + 1, &readmask, NULL, NULL, NULL);

        if (rc <= 0) {

            error(1, errno, "select failed");

        }

        if (FD_ISSET(socket_fd, &readmask)) {

            n = read(socket_fd, recv_line, MAXLINE);

            if (n < 0) {

                error(1, errno, "read error");

            } else if (n == 0) {

                error(1, 0, "server terminated \n");

            }

            recv_line[n] = 0;

            fputs(recv_line, stdout);

            fputs("\n", stdout);

        }

        if (FD_ISSET(STDIN_FILENO, &readmask)) {

            if (fgets(send_line, MAXLINE, stdin) != NULL) {

                int i = strlen(send_line);

                if (send_line[i - 1] == '\n') {

                    send_line[i - 1] = 0;

                }

                printf("now sending %s\n", send_line);

                size_t rt = write(socket_fd, send_line, strlen(send_line));

                if (rt < 0) {

                    error(1, errno, "write failed ");

                }

                printf("send bytes: %zu \n", rt);

            }

        }

    }

}

程序的 12 行通过 FD_ZERO 初始化了一个描述符集合，这个描述符读集合是空的：

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

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

这个时候 select 调用返回，可以使用 FD_ISSET 来判断哪个描述符准备好可读了。如上图所示，这个时候是标准输入可读，37-51 行程序读入后发送给对端。

如果是连接描述字准备好可读了，第 24 行判断为真，使用 read 将套接字数据读出。

我们需要注意的是，这个程序的 17-18 行非常重要，初学者很容易在这里掉坑里去。

第 17 行是每次测试完之后，重新设置待测试的描述符集合。你可以看到上面的例子，在 select 测试之前的数据是{0,3}，select 测试之后就变成了{0}。

这是因为 select 调用每次完成测试之后，内核都会修改描述符集合，通过修改完的描述符集合来和应用程序交互，应用程序使用 FD_ISSET 来对每个描述符进行判断，从而知道什么样的事件发生。

第 18 行则是使用 socket_fd+1 来表示待测试的描述符基数。切记需要 +1。

套接字描述符就绪条件

当我们说 select 测试返回，某个套接字准备好可读，表示什么样的事件发生呢？

第一种情况是套接字接收缓冲区有数据可以读，如果我们使用 read 函数去执行读操作，肯定不会被阻塞，而是会直接读到这部分数据。

第二种情况是对方发送了 FIN，使用 read 函数执行读操作，不会被阻塞，直接返回 0。

第三种情况是针对一个监听套接字而言的，有已经完成的连接建立，此时使用 accept 函数去执行不会阻塞，直接返回已经完成的连接。

第四种情况是套接字有错误待处理，使用 read 函数去执行读操作，不阻塞，且返回 -1。

总结成一句话就是，内核通知我们套接字有数据可以读了，使用 read 函数不会阻塞。

不知道你是不是和我一样，刚开始理解某个套接字可写的时候，会有一个错觉，总是从应用程序角度出发去理解套接字可写，我开始是这样想的，当应用程序完成相应的计算，有数据准备发送给对端了，可以往套接字写，对应的就是套接字可写。

其实这个理解是非常不正确的，select 检测套接字可写，完全是基于套接字本身的特性来说的，具体来说有以下几种情况。

第一种是套接字发送缓冲区足够大，如果我们使用非阻塞套接字进行 write 操作，将不会被阻塞，直接返回。

第二种是连接的写半边已经关闭，如果继续进行写操作将会产生 SIGPIPE 信号。

第三种是套接字上有错误待处理，使用 write 函数去执行读操作，不阻塞，且返回 -1。

总结成一句话就是，内核通知我们套接字可以往里写了，使用 write 函数就不会阻塞。

总结

今天我讲了 select 函数的使用。select 函数提供了最基本的 I/O 多路复用方法，在使用 select 时，我们需要建立两个重要的认识：

描述符基数是当前最大描述符 +1；

每次 select 调用完成之后，记得要重置待测试集合。

思考题

和往常一样，给大家布置两道思考题：

第一道， select 可以对诸如 UNIX 管道 (pipe) 这样的描述字进行检测么？如果可以，检测的就绪条件是什么呢？

第二道，根据我们前面的描述，一个描述符集合哪些描述符被设置为 1，需要进行检测是完全可以知道的，你认为 select 函数里一定需要传入描述字基数这个值么？请你分析一下这样设计的目的又是什么呢？

欢迎你在评论区写下你的思考，也欢迎把这篇文章分享给你的朋友或者同事，一起交流一下。

21 | poll：另一种I/O多路复用

你好，我是盛延敏，这是网络编程实战第 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 里，如何配合进行改造呢？

欢迎你在评论区写下你的思考，也欢迎把这篇文章分享给你的朋友或者同事，一起交流一下。

22 | 非阻塞I/O：提升性能的加速器

你好，我是盛延敏，这里是网络编程实战第 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) {

           /* 这里 EINTR 是非阻塞 non-blocking 情况下，通知我们再次调用 write() */

            if (nwritten < 0 && errno == EINTR)

                nwritten = 0;

            else

                return -1;         /* 出错退出 */

        }

        /* 指针增大，剩下字节数变小 */

        nleft -= nwritten;

        ptr   += nwritten;

    }

    return n;

}

下面我通过一张表来总结一下 read 和 write 在阻塞模式和非阻塞模式下的不同行为特性：

关于 read 和 write 还有几个结论，你需要把握住：

read 总是在接收缓冲区有数据时就立即返回，不是等到应用程序给定的数据充满才返回。当接收缓冲区为空时，阻塞模式会等待，非阻塞模式立即返回 -1，并有 EWOULDBLOCK 或 EAGAIN 错误。

和 read 不同，阻塞模式下，write 只有在发送缓冲区足以容纳应用程序的输出字节时才返回；而非阻塞模式下，则是能写入多少就写入多少，并返回实际写入的字节数。

阻塞模式下的 write 有个特例, 就是对方主动关闭了套接字，这个时候 write 调用会立即返回，并通过返回值告诉应用程序实际写入的字节数，如果再次对这样的套接字进行 write 操作，就会返回失败。失败是通过返回值 -1 来通知到应用程序的。

accept

当 accept 和 I/O 多路复用 select、poll 等一起配合使用时，如果在监听套接字上触发事件，说明有连接建立完成，此时调用 accept 肯定可以返回已连接套接字。这样看来，似乎把监听套接字设置为非阻塞，没有任何好处。

为了说明这个问题，我们构建一个客户端程序，其中最关键的是，一旦连接建立，设置 SO_LINGER 套接字选项，把 l_onoff 标志设置为 1，把 l_linger 时间设置为 0。这样，连接被关闭时，TCP 套接字上将会发送一个 RST。

struct linger ling;

ling.l_onoff = 1;

ling.l_linger = 0;

setsockopt(socket_fd, SOL_SOCKET, SO_LINGER, &ling, sizeof(ling));

close(socket_fd);

服务器端使用 select I/O 多路复用，不过，监听套接字仍然是 blocking 的。如果监听套接字上有事件发生，休眠 5 秒，以便模拟高并发场景下的情形。

if (FD_ISSET(listen_fd, &readset)) {

    printf("listening socket readable\n");

    sleep(5);

    struct sockaddr_storage ss;

    socklen_t slen = sizeof(ss);

    int fd = accept(listen_fd, (struct sockaddr *) &ss, &slen);

这里的休眠时间非常关键，这样，在监听套接字上有可读事件发生时，并没有马上调用 accept。由于客户端发生了 RST 分节，该连接被接收端内核从自己的已完成队列中删除了，此时再调用 accept，由于没有已完成连接（假设没有其他已完成连接），accept 一直阻塞，更为严重的是，该线程再也没有机会对其他 I/O 事件进行分发，相当于该服务器无法对新连接和其他 I/O 进行服务。

如果我们将监听套接字设为非阻塞，上述的情形就不会再发生。只不过对于 accept 的返回值，需要正确地处理各种看似异常的错误，例如忽略 EWOULDBLOCK、EAGAIN 等。

这个例子给我们的启发是，一定要将监听套接字设置为非阻塞的，尽管这里休眠时间 5 秒有点夸张，但是在极端情况下处理不当的服务器程序是有可能碰到文稿中例子所阐述的情况，为了让服务器程序在极端情况下工作正常，这点工作还是非常值得的。

connect

在非阻塞 TCP 套接字上调用 connect 函数，会立即返回一个 EINPROGRESS 错误。TCP 三次握手会正常进行，应用程序可以继续做其他初始化的事情。当该连接建立成功或者失败时，通过 I/O 多路复用 select、poll 等可以进行连接的状态检测。

非阻塞 I/O + select 多路复用

文稿中给出了一个非阻塞 I/O 搭配 select 多路复用的例子。

#define MAX_LINE 1024

#define FD_INIT_SIZE 128

char rot13_char(char c) {

    if ((c >= 'a' && c <= 'm') || (c >= 'A' && c <= 'M'))

        return c + 13;

    else if ((c >= 'n' && c <= 'z') || (c >= 'N' && c <= 'Z'))

        return c - 13;

    else

        return c;

}

// 数据缓冲区

struct Buffer {

    int connect_fd;  // 连接字

    char buffer[MAX_LINE];  // 实际缓冲

    size_t writeIndex;      // 缓冲写入位置

    size_t readIndex;       // 缓冲读取位置

    int readable;           // 是否可以读

};

struct Buffer *alloc_Buffer() {

    struct Buffer *buffer = malloc(sizeof(struct Buffer));

    if (!buffer)

        return NULL;

    buffer->connect_fd = 0;

    buffer->writeIndex = buffer->readIndex = buffer->readable = 0;

    return buffer;

}

void free_Buffer(struct Buffer *buffer) {

    free(buffer);

}

int onSocketRead(int fd, struct Buffer *buffer) {

    char buf[1024];

    int i;

    ssize_t result;

    while (1) {

        result = recv(fd, buf, sizeof(buf), 0);

        if (result <= 0)

            break;

        for (i = 0; i < result; ++i) {

            if (buffer->writeIndex < sizeof(buffer->buffer))

                buffer->buffer[buffer->writeIndex++] = rot13_char(buf[i]);

            if (buf[i] == '\n') {

                buffer->readable = 1;  // 缓冲区可以读

            }

        }

    }

    if (result == 0) {

        return 1;

    } else if (result < 0) {

        if (errno == EAGAIN)

            return 0;

        return -1;

    }

    return 0;

}

int onSocketWrite(int fd, struct Buffer *buffer) {

    while (buffer->readIndex < buffer->writeIndex) {

        ssize_t result = send(fd, buffer->buffer + buffer->readIndex, buffer->writeIndex - buffer->readIndex, 0);

        if (result < 0) {

            if (errno == EAGAIN)

                return 0;

            return -1;

        }

        buffer->readIndex += result;

    }

    if (buffer->readIndex == buffer->writeIndex)

        buffer->readIndex = buffer->writeIndex = 0;

    buffer->readable = 0;

    return 0;

}

int main(int argc, char **argv) {

    int listen_fd;

    int i, maxfd;

    struct Buffer *buffer[FD_INIT_SIZE];

    for (i = 0; i < FD_INIT_SIZE; ++i) {

        buffer[i] = alloc_Buffer();

    }

    listen_fd = tcp_nonblocking_server_listen(SERV_PORT);

    fd_set readset, writeset, exset;

    FD_ZERO(&readset);

    FD_ZERO(&writeset);

    FD_ZERO(&exset);

    while (1) {

        maxfd = listen_fd;

        FD_ZERO(&readset);

        FD_ZERO(&writeset);

        FD_ZERO(&exset);

        // listener 加入 readset

        FD_SET(listen_fd, &readset);

        for (i = 0; i < FD_INIT_SIZE; ++i) {

            if (buffer[i]->connect_fd > 0) {

                if (buffer[i]->connect_fd > maxfd)

                    maxfd = buffer[i]->connect_fd;

                FD_SET(buffer[i]->connect_fd, &readset);

                if (buffer[i]->readable) {

                    FD_SET(buffer[i]->connect_fd, &writeset);

                }

            }

        }

        if (select(maxfd + 1, &readset, &writeset, &exset, NULL) < 0) {

            error(1, errno, "select error");

        }

        if (FD_ISSET(listen_fd, &readset)) {

            printf("listening socket readable\n");

            sleep(5);

            struct sockaddr_storage ss;

            socklen_t slen = sizeof(ss);

           int fd = accept(listen_fd, (struct sockaddr *) &ss, &slen);

            if (fd < 0) {

                error(1, errno, "accept failed");

            } else if (fd > FD_INIT_SIZE) {

                error(1, 0, "too many connections");

                close(fd);

            } else {

                make_nonblocking(fd);

                if (buffer[fd]->connect_fd == 0) {

                    buffer[fd]->connect_fd = fd;

                } else {

                    error(1, 0, "too many connections");

                }

            }

        }

        for (i = 0; i < maxfd + 1; ++i) {

            int r = 0;

            if (i == listen_fd)

                continue;

            if (FD_ISSET(i, &readset)) {

                r = onSocketRead(i, buffer[i]);

            }

            if (r == 0 && FD_ISSET(i, &writeset)) {

                r = onSocketWrite(i, buffer[i]);

            }

            if (r) {

                buffer[i]->connect_fd = 0;

                close(i);

            }

        }

    }

}

第 93 行，调用 fcntl 将监听套接字设置为非阻塞。

fcntl(fd, F_SETFL, O_NONBLOCK);

第 121 行调用 select 进行 I/O 事件分发处理。

131-142 行在处理新的连接套接字，注意这里也把连接套接字设置为非阻塞的。

151-156 行在处理连接套接字上的 I/O 读写事件，这里我们抽象了一个 Buffer 对象，Buffer 对象使用了 readIndex 和 writeIndex 分别表示当前缓冲的读写位置。

实验

启动该服务器：

$./nonblockingserver

使用多个 telnet 客户端连接该服务器，可以验证交互正常。

$telnet 127.0.0.1 43211

Trying 127.0.0.1...

Connected to localhost.

Escape character is '^]'.

fasfasfasf

snfsnfsnfs

总结

非阻塞 I/O 可以使用在 read、write、accept、connect 等多种不同的场景，在非阻塞 I/O 下，使用轮询的方式引起 CPU 占用率高，所以一般将非阻塞 I/O 和 I/O 多路复用技术 select、poll 等搭配使用，在非阻塞 I/O 事件发生时，再调用对应事件的处理函数。这种方式，极大地提高了程序的健壮性和稳定性，是 Linux 下高性能网络编程的首选。

思考题

给大家布置两道思考题:

第一道，程序中第 133 行这个判断说明了什么？如果要改进的话，你有什么想法？

else if (fd > FD_INIT_SIZE) {

    error(1, 0, "too many connections");

    close(fd);

第二道，你可以仔细阅读一下数据读写部分 Buffer 的代码，你觉得用一个 Buffer 对象，而不是两个的目的是什么？

欢迎在评论区写下你的思考，我会和你一起交流，也欢迎把这篇文章分享给你的朋友或者同事，一起交流一下。

23 | Linux利器：epoll的前世今生

你好，我是盛延敏，这里是网络编程实战第 23 讲，欢迎回来。

性能篇的前三讲，非阻塞 I/O 加上 I/O 多路复用，已经渐渐帮助我们在高性能网络编程这个领域搭建了初步的基石。但是，离最终的目标还差那么一点，如果说 I/O 多路复用帮我们打开了高性能网络编程的窗口，那么今天的主题——epoll，将为我们增添足够的动力。

我在文稿中放置了一张图，这张图来自 The Linux Programming Interface(No Starch Press)。这张图直观地为我们展示了 select、poll、epoll 几种不同的 I/O 复用技术在面对不同文件描述符大小时的表现差异。

从图中可以明显地看到，epoll 的性能是最好的，即使在多达 10000 个文件描述的情况下，其性能的下降和有 10 个文件描述符的情况相比，差别也不是很大。而随着文件描述符的增大，常规的 select 和 poll 方法性能逐渐变得很差。

那么，epoll 究竟使用了什么样的“魔法”，取得了如此令人惊讶的效果呢？接下来，我们就来一起分析一下。

epoll 的用法

在分析对比 epoll、poll 和 select 几种技术之前，我们先看一下怎么使用 epoll 来完成一个服务器程序，具体的原理我将在 29 讲中进行讲解。

epoll 可以说是和 poll 非常相似的一种 I/O 多路复用技术，有些朋友将 epoll 归为异步 I/O，我觉得这是不正确的。本质上 epoll 还是一种 I/O 多路复用技术， epoll 通过监控注册的多个描述字，来进行 I/O 事件的分发处理。不同于 poll 的是，epoll 不仅提供了默认的 level-triggered（条件触发）机制，还提供了性能更为强劲的 edge-triggered（边缘触发）机制。至于这两种机制的区别，我会在后面详细展开。

使用 epoll 进行网络程序的编写，需要三个步骤，分别是 epoll_create，epoll_ctl 和 epoll_wait。接下来我对这几个 API 详细展开讲一下。

epoll_create

int epoll_create(int size);

int epoll_create1(int flags);

        返回值: 若成功返回一个大于 0 的值，表示 epoll 实例；若返回 -1 表示出错

epoll_create() 方法创建了一个 epoll 实例，从 Linux 2.6.8 开始，参数 size 被自动忽略，但是该值仍需要一个大于 0 的整数。这个 epoll 实例被用来调用 epoll_ctl 和 epoll_wait，如果这个 epoll 实例不再需要，比如服务器正常关机，需要调用 close() 方法释放 epoll 实例，这样系统内核可以回收 epoll 实例所分配使用的内核资源。

关于这个参数 size，在一开始的 epoll_create 实现中，是用来告知内核期望监控的文件描述字大小，然后内核使用这部分的信息来初始化内核数据结构，在新的实现中，这个参数不再被需要，因为内核可以动态分配需要的内核数据结构。我们只需要注意，每次将 size 设置成一个大于 0 的整数就可以了。

epoll_create1() 的用法和 epoll_create() 基本一致，如果 epoll_create1() 的输入 size 大小为 0，则和 epoll_create() 一样，内核自动忽略。可以增加如 EPOLL_CLOEXEC 的额外选项，如果你有兴趣的话，可以研究一下这个选项有什么意义。

epoll_ctl

 int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

        返回值: 若成功返回 0；若返回 -1 表示出错

在创建完 epoll 实例之后，可以通过调用 epoll_ctl 往这个 epoll 实例增加或删除监控的事件。函数 epll_ctl 有 4 个入口参数。

第一个参数 epfd 是刚刚调用 epoll_create 创建的 epoll 实例描述字，可以简单理解成是 epoll 句柄。

第二个参数表示增加还是删除一个监控事件，它有三个选项可供选择：

EPOLL_CTL_ADD： 向 epoll 实例注册文件描述符对应的事件；

EPOLL_CTL_DEL：向 epoll 实例删除文件描述符对应的事件；

EPOLL_CTL_MOD： 修改文件描述符对应的事件。

第三个参数是注册的事件的文件描述符，比如一个监听套接字。

第四个参数表示的是注册的事件类型，并且可以在这个结构体里设置用户需要的数据，其中最为常见的是使用联合结构里的 fd 字段，表示事件所对应的文件描述符。

typedef union epoll_data {

     void        *ptr;

     int          fd;

     uint32_t     u32;

     uint64_t     u64;

 } epoll_data_t;

 struct epoll_event {

     uint32_t     events;      /* Epoll events */

     epoll_data_t data;        /* User data variable */

 };

我们在前面介绍 poll 的时候已经接触过基于 mask 的事件类型了，这里 epoll 仍旧使用了同样的机制，我们重点看一下这几种事件类型：

EPOLLIN：表示对应的文件描述字可以读；

EPOLLOUT：表示对应的文件描述字可以写；

EPOLLRDHUP：表示套接字的一端已经关闭，或者半关闭；

EPOLLHUP：表示对应的文件描述字被挂起；

EPOLLET：设置为 edge-triggered，默认为 level-triggered。

epoll_wait

int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

  返回值: 成功返回的是一个大于 0 的数，表示事件的个数；返回 0 表示的是超时时间到；若出错返回 -1.

epoll_wait() 函数类似之前的 poll 和 select 函数，调用者进程被挂起，在等待内核 I/O 事件的分发。

这个函数的第一个参数是 epoll 实例描述字，也就是 epoll 句柄。

第二个参数返回给用户空间需要处理的 I/O 事件，这是一个数组，数组的大小由 epoll_wait 的返回值决定，这个数组的每个元素都是一个需要待处理的 I/O 事件，其中 events 表示具体的事件类型，事件类型取值和 epoll_ctl 可设置的值一样，这个 epoll_event 结构体里的 data 值就是在 epoll_ctl 那里设置的 data，也就是用户空间和内核空间调用时需要的数据。

第三个参数是一个大于 0 的整数，表示 epoll_wait 可以返回的最大事件值。

第四个参数是 epoll_wait 阻塞调用的超时值，如果这个值设置为 -1，表示不超时；如果设置为 0 则立即返回，即使没有任何 I/O 事件发生。

epoll 例子

代码解析

下面我们把原先基于 poll 的服务器端程序改造成基于 epoll 的：

#include "lib/common.h"

#define MAXEVENTS 128

char rot13_char(char c) {

    if ((c >= 'a' && c <= 'm') || (c >= 'A' && c <= 'M'))

        return c + 13;

    else if ((c >= 'n' && c <= 'z') || (c >= 'N' && c <= 'Z'))

        return c - 13;

    else

        return c;

}

int main(int argc, char **argv) {

    int listen_fd, socket_fd;

    int n, i;

    int efd;

    struct epoll_event event;

    struct epoll_event *events;

    listen_fd = tcp_nonblocking_server_listen(SERV_PORT);

    efd = epoll_create1(0);

    if (efd == -1) {

        error(1, errno, "epoll create failed");

    }

    event.data.fd = listen_fd;

    event.events = EPOLLIN | EPOLLET;

    if (epoll_ctl(efd, EPOLL_CTL_ADD, listen_fd, &event) == -1) {

        error(1, errno, "epoll_ctl add listen fd failed");

    }

    /* Buffer where events are returned */

    events = calloc(MAXEVENTS, sizeof(event));

    while (1) {

        n = epoll_wait(efd, events, MAXEVENTS, -1);

        printf("epoll_wait wakeup\n");

        for (i = 0; i < n; i++) {

            if ((events[i].events & EPOLLERR) ||

                (events[i].events & EPOLLHUP) ||

                (!(events[i].events & EPOLLIN))) {

                fprintf(stderr, "epoll error\n");

                close(events[i].data.fd);

                continue;

            } else if (listen_fd == events[i].data.fd) {

                struct sockaddr_storage ss;

                socklen_t slen = sizeof(ss);

                int fd = accept(listen_fd, (struct sockaddr *) &ss, &slen);

                if (fd < 0) {

                    error(1, errno, "accept failed");

                } else {

                    make_nonblocking(fd);

                    event.data.fd = fd;

                    event.events = EPOLLIN | EPOLLET; //edge-triggered

                    if (epoll_ctl(efd, EPOLL_CTL_ADD, fd, &event) == -1) {

                        error(1, errno, "epoll_ctl add connection fd failed");

                    }

                }

                continue;

            } else {

                socket_fd = events[i].data.fd;

                printf("get event on socket fd == %d \n", socket_fd);

                while (1) {

                    char buf[512];

                    if ((n = read(socket_fd, buf, sizeof(buf))) < 0) {

                        if (errno != EAGAIN) {

                            error(1, errno, "read error");

                            close(socket_fd);

                        }

                        break;

                    } else if (n == 0) {

                        close(socket_fd);

                        break;

                    } else {

                        for (i = 0; i < n; ++i) {

                            buf[i] = rot13_char(buf[i]);

                        }

                        if (write(socket_fd, buf, n) < 0) {

                            error(1, errno, "write error");

                        }

                    }

                }

            }

        }

    }

    free(events);

    close(listen_fd);

}

程序的第 23 行调用 epoll_create0 创建了一个 epoll 实例。

28-32 行，调用 epoll_ctl 将监听套接字对应的 I/O 事件进行了注册，这样在有新的连接建立之后，就可以感知到。注意这里使用的是 edge-triggered（边缘触发）。

35 行为返回的 event 数组分配了内存。

主循环调用 epoll_wait 函数分发 I/O 事件，当 epoll_wait 成功返回时，通过遍历返回的 event 数组，就直接可以知道发生的 I/O 事件。

第 41-46 行判断了各种错误情况。

第 47-61 行是监听套接字上有事件发生的情况下，调用 accept 获取已建立连接，并将该连接设置为非阻塞，再调用 epoll_ctl 把已连接套接字对应的可读事件注册到 epoll 实例中。这里我们使用了 event_data 里面的 fd 字段，将连接套接字存储其中。

第 63-84 行，处理了已连接套接字上的可读事件，读取字节流，编码后再回应给客户端。

实验

启动该服务器：

$./epoll01

epoll_wait wakeup

epoll_wait wakeup

epoll_wait wakeup

get event on socket fd == 6

epoll_wait wakeup

get event on socket fd == 5

epoll_wait wakeup

get event on socket fd == 5

epoll_wait wakeup

get event on socket fd == 6

epoll_wait wakeup

get event on socket fd == 6

epoll_wait wakeup

get event on socket fd == 6

epoll_wait wakeup

get event on socket fd == 5

再启动几个 telnet 客户端，可以看到有连接建立情况下，epoll_wait 迅速从挂起状态结束；并且套接字上有数据可读时，epoll_wait 也迅速结束挂起状态，这时候通过 read 可以读取套接字接收缓冲区上的数据。

$telnet 127.0.0.1 43211

Trying 127.0.0.1...

Connected to 127.0.0.1.

Escape character is '^]'.

fasfsafas

snfsfnsnf

^]

telnet> quit

Connection closed.

edge-triggered VS level-triggered

对于 edge-triggered 和 level-triggered， 官方的说法是一个是边缘触发，一个是条件触发。也有文章从电子脉冲角度来解读的，总体上，给初学者的带来的感受是理解上有困难。

我在文稿里面给了两个程序，我们用这个程序来说明一下这两者之间的不同。

在这两个程序里，即使已连接套接字上有数据可读，我们也不调用 read 函数去读，只是简单地打印出一句话。

第一个程序我们设置为 edge-triggered，即边缘触发。开启这个服务器程序，用 telnet 连接上，输入一些字符，我们看到，服务器端只从 epoll_wait 中苏醒过一次，就是第一次有数据可读的时候。

$./epoll02

epoll_wait wakeup

epoll_wait wakeup

get event on socket fd == 5

$telnet 127.0.0.1 43211

Trying 127.0.0.1...

Connected to 127.0.0.1.

Escape character is '^]'.

asfafas

第二个程序我们设置为 level-triggered，即条件触发。然后按照同样的步骤来一次，观察服务器端，这一次我们可以看到，服务器端不断地从 epoll_wait 中苏醒，告诉我们有数据需要读取。

$./epoll03

epoll_wait wakeup

epoll_wait wakeup

get event on socket fd == 5

epoll_wait wakeup

get event on socket fd == 5

epoll_wait wakeup

get event on socket fd == 5

epoll_wait wakeup

get event on socket fd == 5

...

这就是两者的区别，条件触发的意思是只要满足事件的条件，比如有数据需要读，就一直不断地把这个事件传递给用户；而边缘触发的意思是只有第一次满足条件的时候才触发，之后就不会再传递同样的事件了。

一般我们认为，边缘触发的效率比条件触发的效率要高，这一点也是 epoll 的杀手锏之一。

epoll 的历史

早在 Linux 实现 epoll 之前，Windows 系统就已经在 1994 年引入了 IOCP，这是一个异步 I/O 模型，用来支持高并发的网络 I/O，而著名的 FreeBSD 在 2000 年引入了 Kqueue——一个 I/O 事件分发框架。

Linux 在 2002 年引入了 epoll，不过相关工作的讨论和设计早在 2000 年就开始了。如果你感兴趣的话，可以http://lkml.iu.edu/hypermail/linux/kernel/0010.3/0003.html"> 点击这里看一下里面的讨论。

为什么 Linux 不把 FreeBSD 的 kqueue 直接移植过来，而是另辟蹊径创立了 epoll 呢？

让我们先看下 kqueue 的用法，kqueue 也需要先创建一个名叫 kqueue 的对象，然后通过这个对象，调用 kevent 函数增加感兴趣的事件，同时，也是通过这个 kevent 函数来等待事件的发生。

int kqueue(void);

int kevent(int kq, const struct kevent *changelist, int nchanges,

　　　　　 struct kevent *eventlist, int nevents,

　　　　　 const struct timespec *timeout);

void EV_SET(struct kevent *kev, uintptr_t ident, short filter,

　　　　　　u_short flags, u_int fflags, intptr_t data, void *udata);

struct kevent {

　uintptr_t　ident;　　　/* identifier (e.g., file descriptor) */

　short　　　 filter;　　/* filter type (e.g., EVFILT_READ) */

　u_short　　 flags;　　　/* action flags (e.g., EV_ADD) */

　u_int　　　　fflags;　　/* filter-specific flags */

　intptr_t　　 data;　　　/* filter-specific data */

　void　　　　 *udata;　　 /* opaque user data */

};

Linus 在他最初的设想里，提到了这么一句话，也就是说他觉得类似 select 或 poll 的数组方式是可以的，而队列方式则是不可取的。

So sticky arrays of events are good, while queues are bad. Let’s take that as one of the fundamentals.

在最初的设计里，Linus 等于把 keque 里面的 kevent 函数拆分了两个部分，一部分负责事件绑定，通过 bind_event 函数来实现；另一部分负责事件等待，通过 get_events 来实现。

struct event {

     unsigned long id; /* file descriptor ID the event is on */

     unsigned long event; /* bitmask of active events */

};

int bind_event(int fd, struct event *event);

int get_events(struct event * event_array, int maxnr, struct timeval *tmout);

和最终的 epoll 实现相比，前者类似 epoll_ctl，后者类似 epoll_wait，不过原始的设计里没有考虑到创建 epoll 句柄，在最终的实现里增加了 epoll_create，支持了 epoll 句柄的创建。

2002 年，epoll 最终在 Linux 2.5.44 中首次出现，在 2.6 中趋于稳定，为 Linux 的高性能网络 I/O 画上了一段句号。

总结

Linux 中 epoll 的出现，为高性能网络编程补齐了最后一块拼图。epoll 通过改进的接口设计，避免了用户态 - 内核态频繁的数据拷贝，大大提高了系统性能。在使用 epoll 的时候，我们一定要理解条件触发和边缘触发两种模式。条件触发的意思是只要满足事件的条件，比如有数据需要读，就一直不断地把这个事件传递给用户；而边缘触发的意思是只有第一次满足条件的时候才触发，之后就不会再传递同样的事件了。

思考题

理解完了 epoll，和往常一样，我给你布置两道思考题：

第一道，你不妨试着修改一下第 20 讲中 select 的例子，即在已连接套接字上有数据可读，也不调用 read 函数去读，看一看你的结果，你认为 select 是边缘触发的，还是条件触发的？

第二道，同样的修改一下第 21 讲 poll 的例子，看看你的结果，你认为 poll 是边缘触发的，还是条件触发的？

你可以在 GitHub 上上传你的代码，并写出你的疑惑，我会和你一起交流，也欢迎把这篇文章分享给你的朋友或者同事，一起交流一下。

24 | C10K问题：高并发模型设计

你好，我是盛延敏，这里是网络编程实战第 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"> 这个页面来获得最新有关这方面的信息。

操作系统层面

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/8 = 20M bytes

接收缓冲区： 87380*10000/8 = 110M 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 问题，即单机处理千万级并发，你认为能实现吗？挑战和瓶颈又在哪里呢？

欢迎你在评论区写下你对这两个问题的思考，我会和你一起交流，也欢迎把这篇文章分享给你的朋友或者同事，一起交流一下。

25 | 使用阻塞I/O和进程模型：最传统的方式

你好，我是盛延敏，这里是网络编程实战第 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 上。当第一个客户端发起连接请求，连接建立后产生出连接套接字，此时，父进程派生出一个子进程，在子进程中，使用连接套接字和客户端通信，因此子进程不需要关心监听套接字，只需要关心连接套接字；父进程则相反，将客户服务交给子进程来处理，因此父进程不需要关心连接套接字，只需要关心监听套接字。

这张图描述了从连接请求到连接建立，父进程派生子进程为客户服务。

假设父进程之后又接收了新的连接请求，从 accept 调用返回新的已连接套接字，父进程又派生出另一个子进程，这个子进程用第二个已连接套接字为客户端服务。

这张图同样描述了这个过程。

现在，服务器端的父进程继续监听在套接字上，等待新的客户连接到来；两个子进程分别使用两个不同的连接套接字为两个客户服务。

程序讲解

我们将前面的内容串联起来，就是下面完整的一个基于进程模型的服务器端程序。

#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 讲中讲到的 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 信号时，使用了一个循环来回收处理终止的子进程，为什么要这么做呢？如果不使用循环会有什么后果？

欢迎你在评论区写下你的思考，我会和你一起交流，也欢迎把这篇文章分享给你的朋友或者同事，一起交流一下。

26 | 使用阻塞I/O和线程模型：换一种轻量的方式

你好，我是盛延敏，这里是网络编程实战第 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.

构建线程池处理多个连接

上面的服务器端程序虽然可以正常工作，不过它有一个缺点，那就是如果并发连接过多，就会引起线程的频繁创建和销毁。虽然线程切换的上下文开销不大，但是线程创建和销毁的开销却是不小的。

能不能对这个程序进行一些优化呢？

我们可以使用预创建线程池的方式来进行优化。在服务器端启动时，可以先按照固定大小预创建出多个线程，当有新连接建立时，往连接字队列里放置这个新连接描述字，线程池里的线程负责从连接字队列里取出连接描述字进行处理。

这个程序的关键是连接字队列的设计，因为这里既有往这个队列里放置描述符的操作，也有从这个队列里取出描述符的操作。

对此，需要引入两个重要的概念，一个是锁 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 计数器应用时，说“结果是幸运”这是为什么呢？怎么理解呢？

欢迎你在评论区写下你的思考，我会和你一起思考，也欢迎把这篇文章分享给你的朋友或者同事，一起交流一下。

27 | I/O多路复用遇上线程：使用poll单线程处理所有I/O事件

你好，我是盛延敏，这里是网络编程实战第 27 讲，欢迎回来。

我在前面两讲里，分别使用了 fork 进程和 pthread 线程来处理多并发，这两种技术使用简单，但是性能却会随着并发数的上涨而快速下降，并不能满足极端高并发的需求。就像第 24 讲中讲到的一样，这个时候我们需要寻找更好的解决之道，这个解决之道基本的思想就是 I/O 事件分发。

关于文稿中的代码，你可以去GitHub上查看或下载完整代码。

重温事件驱动

基于事件的程序设计: GUI、Web

事件驱动的好处是占用资源少，效率高，可扩展性强，是支持高性能高并发的不二之选。

如果你熟悉 GUI 编程的话，你就会知道，GUI 设定了一系列的控件，如 Button、Label、文本框等，当我们设计基于控件的程序时，一般都会给 Button 的点击安排一个函数，类似这样：

// 按钮点击的事件处理

void onButtonClick(){

}

这个设计的思想是，一个无限循环的事件分发线程在后台运行，一旦用户在界面上产生了某种操作，例如点击了某个 Button，或者点击了某个文本框，一个事件会被产生并放置到事件队列中，这个事件会有一个类似前面的 onButtonClick 回调函数。事件分发线程的任务，就是为每个发生的事件找到对应的事件回调函数并执行它。这样，一个基于事件驱动的 GUI 程序就可以完美地工作了。

还有一个类似的例子是 Web 编程领域。同样的，Web 程序会在 Web 界面上放置各种界面元素，例如 Label、文本框、按钮等，和 GUI 程序类似，给感兴趣的界面元素设计 JavaScript 回调函数，当用户操作时，对应的 JavaScript 回调函数会被执行，完成某个计算或操作。这样，一个基于事件驱动的 Web 程序就可以在浏览器中完美地工作了。

在第 24 讲中，我们已经提到，通过使用 poll、epoll 等 I/O 分发技术，可以设计出基于套接字的事件驱动程序，从而满足高性能、高并发的需求。

事件驱动模型，也被叫做反应堆模型（reactor），或者是 Event loop 模型。这个模型的核心有两点。

第一，它存在一个无限循环的事件分发线程，或者叫做 reactor 线程、Event loop 线程。这个事件分发线程的背后，就是 poll、epoll 等 I/O 分发技术的使用。

第二，所有的 I/O 操作都可以抽象成事件，每个事件必须有回调函数来处理。acceptor 上有连接建立成功、已连接套接字上发送缓冲区空出可以写、通信管道 pipe 上有数据可以读，这些都是一个个事件，通过事件分发，这些事件都可以一一被检测，并调用对应的回调函数加以处理。

几种 I/O 模型和线程模型设计

任何一个网络程序，所做的事情可以总结成下面几种：

read：从套接字收取数据；

decode：对收到的数据进行解析；

compute：根据解析之后的内容，进行计算和处理；

encode：将处理之后的结果，按照约定的格式进行编码；

send：最后，通过套接字把结果发送出去。

这几个过程和套接字最相关的是 read 和 send 这两种。接下来，我们总结一下已经学过的几种支持多并发的网络编程技术，引出我们今天的话题，使用 poll 单线程处理所有 I/O。

fork

第 25 讲中，我们使用 fork 来创建子进程，为每个到达的客户连接服务。文稿中的这张图很好地解释了这个设计模式，可想而知的是，随着客户数的变多，fork 的子进程也越来越多，即使客户和服务器之间的交互比较少，这样的子进程也不能被销毁，一直需要存在。使用 fork 的方式处理非常简单，它的缺点是处理效率不高，fork 子进程的开销太大。

pthread

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

single reactor thread

前面讲到，事件驱动模式是解决高性能、高并发比较好的一种模式。为什么呢？

因为这种模式是符合大规模生产的需求的。我们的生活中遍地都是类似的模式。比如你去咖啡店喝咖啡，你点了一杯咖啡在一旁喝着，服务员也不会管你，等你有续杯需求的时候，再去和服务员提（触发事件）, 服务员满足了你的需求，你就继续可以喝着咖啡玩手机。整个柜台的服务方式就是一个事件驱动的方式。

我在文稿中放了一张图解释了这一讲的设计模式。一个 reactor 线程上同时负责分发 acceptor 的事件、已连接套接字的 I/O 事件。

single reactor thread + worker threads

但是上述的设计模式有一个问题，和 I/O 事件处理相比，应用程序的业务逻辑处理是比较耗时的，比如 XML 文件的解析、数据库记录的查找、文件资料的读取和传输、计算型工作的处理等，这些工作相对而言比较独立，它们会拖慢整个反应堆模式的执行效率。

所以，将这些 decode、compute、enode 型工作放置到另外的线程池中，和反应堆线程解耦，是一个比较明智的选择。我在文稿中放置了这样的一张图。反应堆线程只负责处理 I/O 相关的工作，业务逻辑相关的工作都被裁剪成一个一个的小任务，放到线程池里由空闲的线程来执行。当结果完成后，再交给反应堆线程，由反应堆线程通过套接字将结果发送出去。

样例程序

从今天开始，我们会接触到为本课程量身定制的网络编程框架。使用这个网络编程框架的样例程序已经放到文稿中：

#include <lib/acceptor.h>

#include "lib/common.h"

#include "lib/event_loop.h"

#include "lib/tcp_server.h"

char rot13_char(char c) {

    if ((c >= 'a' && c <= 'm') || (c >= 'A' && c <= 'M'))

        return c + 13;

    else if ((c >= 'n' && c <= 'z') || (c >= 'N' && c <= 'Z'))

        return c - 13;

    else

        return c;

}

// 连接建立之后的 callback

int onConnectionCompleted(struct tcp_connection *tcpConnection) {

    printf("connection completed\n");

    return 0;

}

// 数据读到 buffer 之后的 callback

int onMessage(struct buffer *input, struct tcp_connection *tcpConnection) {

    printf("get message from tcp connection %s\n", tcpConnection->name);

    printf("%s", input->data);

    struct buffer *output = buffer_new();

    int size = buffer_readable_size(input);

    for (int i = 0; i < size; i++) {

        buffer_append_char(output, rot13_char(buffer_read_char(input)));

    }

    tcp_connection_send_buffer(tcpConnection, output);

    return 0;

}

// 数据通过 buffer 写完之后的 callback

int onWriteCompleted(struct tcp_connection *tcpConnection) {

    printf("write completed\n");

    return 0;

}

// 连接关闭之后的 callback

int onConnectionClosed(struct tcp_connection *tcpConnection) {

    printf("connection closed\n");

    return 0;

}

int main(int c, char **v) {

    // 主线程 event_loop

    struct event_loop *eventLoop = event_loop_init();

    // 初始化 acceptor

    struct acceptor *acceptor = acceptor_init(SERV_PORT);

    // 初始 tcp_server，可以指定线程数目，如果线程是 0，就只有一个线程，既负责 acceptor，也负责 I/O

    struct TCPserver *tcpServer = tcp_server_init(eventLoop, acceptor, onConnectionCompleted, onMessage,

                                                  onWriteCompleted, onConnectionClosed, 0);

    tcp_server_start(tcpServer);

    // main thread for acceptor

    event_loop_run(eventLoop);

}

这个程序的 main 函数部分只有几行, 因为是第一次接触到，稍微展开介绍一下。

第 49 行创建了一个 event_loop，即 reactor 对象，这个 event_loop 和线程相关联，每个 event_loop 在线程里执行的是一个无限循环，以便完成事件的分发。

第 52 行初始化了 acceptor，用来监听在某个端口上。

第 55 行创建了一个 TCPServer，创建的时候可以指定线程数目，这里线程是 0，就只有一个线程，既负责 acceptor 的连接处理，也负责已连接套接字的 I/O 处理。这里比较重要的是传入了几个回调函数，分别对应了连接建立完成、数据读取完成、数据发送完成、连接关闭完成几种操作，通过回调函数，让业务程序可以聚焦在业务层开发。

第 57 行开启监听。

第 60 行运行 event_loop 无限循环，等待 acceptor 上有连接建立、新连接上有数据可读等。

样例程序结果

运行这个服务器程序，开启两个 telnet 客户端，我们看到服务器端的输出如下：

 $./poll-server-onethread

[msg] set poll as dispatcher

[msg] add channel fd == 4, main thread

[msg] poll added channel fd==4

[msg] add channel fd == 5, main thread

[msg] poll added channel fd==5

[msg] event loop run, main thread

[msg] get message channel i==1, fd==5

[msg] activate channel fd == 5, revents=2, main thread

[msg] new connection established, socket == 6

connection completed

[msg] add channel fd == 6, main thread

[msg] poll added channel fd==6

[msg] get message channel i==2, fd==6

[msg] activate channel fd == 6, revents=2, main thread

get message from tcp connection connection-6

afadsfaf

[msg] get message channel i==2, fd==6

[msg] activate channel fd == 6, revents=2, main thread

get message from tcp connection connection-6

afadsfaf

fdafasf

[msg] get message channel i==1, fd==5

[msg] activate channel fd == 5, revents=2, main thread

[msg] new connection established, socket == 7

connection completed

[msg] add channel fd == 7, main thread

[msg] poll added channel fd==7

[msg] get message channel i==3, fd==7

[msg] activate channel fd == 7, revents=2, main thread

get message from tcp connection connection-7

sfasggwqe

[msg] get message channel i==3, fd==7

[msg] activate channel fd == 7, revents=2, main thread

[msg] poll delete channel fd==7

connection closed

[msg] get message channel i==2, fd==6

[msg] activate channel fd == 6, revents=2, main thread

[msg] poll delete channel fd==6

connection closed

这里自始至终都只有一个 main thread 在工作，可见，单线程的 reactor 处理多个连接时也可以表现良好。

总结

这一讲我们总结了几种不同的 I/O 模型和线程模型设计，并比较了各自不同的优缺点。从这一讲开始，我们将使用自己编写的编程框架来完成业务开发，这一讲使用了 poll 来处理所有的 I/O 事件，在下一讲里，我们将会看到如何把 acceptor 的连接事件和已连接套接字的 I/O 事件交由不同的线程处理，而这个分离，不过是在应用程序层简单的参数配置而已。

思考题

和往常一样，给大家留两道思考题：

你可以试着修改一下 onMessage 方法，把它变为期中作业中提到的 cd、ls 等 command 实现。

文章里服务器端的 decode-compute-encode 是在哪里实现的？你有什么办法来解决业务逻辑和 I/O 逻辑混在一起么？

欢迎你在评论区写下你的思考，或者在 GitHub 上上传你的代码，也欢迎把这篇文章分享给你的朋友或者同事，一起交流一下。

28 | I/O多路复用进阶：子线程使用poll处理连接I/O事件

你好，我是盛延敏，这里是网络编程实战第 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 分发处理的效率。而且，同一个套接字事件分发只会出现在一个反应堆线程中，这会大大减少并发处理的锁开销。

我来解释一下这张图，我们的主反应堆线程一直在感知连接建立的事件，如果有连接成功建立，主反应堆线程通过 accept 方法获取已连接套接字，接下来会按照一定的算法选取一个从反应堆线程，并把已连接套接字加入到选择好的从反应堆线程中。

主反应堆线程唯一的工作，就是调用 accept 获取已连接套接字，以及将已连接套接字加入到从反应堆线程中。不过，这里还有一个小问题，主反应堆线程和从反应堆线程，是两个不同的线程，如何把已连接套接字加入到另外一个线程中呢？更令人沮丧的是，此时从反应堆线程或许处于事件分发的无限循环之中，在这种情况下应该怎么办呢？

我在这里先卖个关子，这是高性能网络程序框架要解决的问题。在实战篇里，我将为这些问题一一解开答案。

主 - 从 reactor+worker threads 模式

如果说主 - 从 reactor 模式解决了 I/O 分发的高效率问题，那么 work threads 就解决了业务逻辑和 I/O 分发之间的耦合问题。把这两个策略组装在一起，就是实战中普遍采用的模式。大名鼎鼎的 Netty，就是把这种模式发挥到极致的一种实现。不过要注意 Netty 里面提到的 worker 线程，其实就是我们这里说的从 reactor 线程，并不是处理具体业务逻辑的 worker 线程。

下面贴的一段代码就是常见的 Netty 初始化代码，这里 Boss Group 就是 acceptor 主反应堆，workerGroup 就是从反应堆。而处理业务逻辑的线程，通常都是通过使用 Netty 的程序开发者进行设计和定制，一般来说，业务逻辑线程需要从 workerGroup 线程中分离，以便支持更高的并发度。

public final class TelnetServer {

    static final int PORT = Integer.parseInt(System.getProperty("port", SSL? "8992" : "8023"));

    public static void main(String[] args) throws Exception {

        // 产生一个 reactor 线程，只负责 accetpor 的对应处理

        EventLoopGroup bossGroup = new NioEventLoopGroup(1);

        // 产生一个 reactor 线程，负责处理已连接套接字的 I/O 事件分发

        EventLoopGroup workerGroup = new NioEventLoopGroup(1);

        try {

           // 标准的 Netty 初始，通过 serverbootstrap 完成线程池、channel 以及对应的 handler 设置，注意这里讲 bossGroup 和 workerGroup 作为参数设置

            ServerBootstrap b = new ServerBootstrap();

            b.group(bossGroup, workerGroup)

             .channel(NioServerSocketChannel.class)

             .handler(new LoggingHandler(LogLevel.INFO))

             .childHandler(new TelnetServerInitializer(sslCtx));

            // 开启两个 reactor 线程无限循环处理

            b.bind(PORT).sync().channel().closeFuture().sync();

        } finally {

            bossGroup.shutdownGracefully();

            workerGroup.shutdownGracefully();

        }

    }

}

这张图解释了主 - 从反应堆下加上 worker 线程池的处理模式。

主 - 从反应堆跟上面介绍的做法是一样的。和上面不一样的是，这里将 decode、compute、encode 等 CPU 密集型的工作从 I/O 线程中拿走，这些工作交给 worker 线程池来处理，而且这些工作拆分成了一个个子任务进行。encode 之后完成的结果再由 sub-reactor 的 I/O 线程发送出去。

样例程序

#include <lib/acceptor.h>

#include "lib/common.h"

#include "lib/event_loop.h"

#include "lib/tcp_server.h"

char rot13_char(char c) {

    if ((c >= 'a' && c <= 'm') || (c >= 'A' && c <= 'M'))

        return c + 13;

    else if ((c >= 'n' && c <= 'z') || (c >= 'N' && c <= 'Z'))

        return c - 13;

    else

        return c;

}

// 连接建立之后的 callback

int onConnectionCompleted(struct tcp_connection *tcpConnection) {

    printf("connection completed\n");

    return 0;

}

// 数据读到 buffer 之后的 callback

int onMessage(struct buffer *input, struct tcp_connection *tcpConnection) {

    printf("get message from tcp connection %s\n", tcpConnection->name);

    printf("%s", input->data);

    struct buffer *output = buffer_new();

    int size = buffer_readable_size(input);

    for (int i = 0; i < size; i++) {

        buffer_append_char(output, rot13_char(buffer_read_char(input)));

    }

    tcp_connection_send_buffer(tcpConnection, output);

    return 0;

}

// 数据通过 buffer 写完之后的 callback

int onWriteCompleted(struct tcp_connection *tcpConnection) {

    printf("write completed\n");

    return 0;

}

// 连接关闭之后的 callback

int onConnectionClosed(struct tcp_connection *tcpConnection) {

    printf("connection closed\n");

    return 0;

}

int main(int c, char **v) {

    // 主线程 event_loop

    struct event_loop *eventLoop = event_loop_init();

    // 初始化 acceptor

    struct acceptor *acceptor = acceptor_init(SERV_PORT);

    // 初始 tcp_server，可以指定线程数目，这里线程是 4，说明是一个 acceptor 线程，4 个 I/O 线程，没一个 I/O 线程

    //tcp_server 自己带一个 event_loop

    struct TCPserver *tcpServer = tcp_server_init(eventLoop, acceptor, onConnectionCompleted, onMessage,

                                                  onWriteCompleted, onConnectionClosed, 4);

    tcp_server_start(tcpServer);

    // main thread for acceptor

    event_loop_run(eventLoop);

}

我们的样例程序几乎和第 27 讲的一样，唯一的不同是在创建 TCPServer 时，线程的数量设置不再是 0，而是 4。这里线程是 4，说明是一个主 acceptor 线程，4 个从 reactor 线程，每一个线程都跟一个 event_loop 一一绑定。

你可能会问，这么简单就完成了主、从线程的配置？

答案是 YES。这其实是设计框架需要考虑的地方，一个框架不仅要考虑性能、扩展性，也需要考虑可用性。可用性部分就是程序开发者如何使用框架。如果我是一个开发者，我肯定关心框架的使用方式是不是足够方便，配置是不是足够灵活等。

像这里，可以根据需求灵活地配置主、从反应堆线程，就是一个易用性的体现。当然，因为时间有限，我没有考虑 woker 线程的部分，这部分其实应该是应用程序自己来设计考虑。网络编程框架通过回调函数暴露了交互的接口，这里应用程序开发者完全可以在 onMessage 方法里面获取一个子线程来处理 encode、compute 和 encode 的工作，像下面的示范代码一样。

// 数据读到 buffer 之后的 callback

int onMessage(struct buffer *input, struct tcp_connection *tcpConnection) {

    printf("get message from tcp connection %s\n", tcpConnection->name);

    printf("%s", input->data);

    // 取出一个线程来负责 decode、compute 和 encode

    struct buffer *output = thread_handle(input);

    // 处理完之后再通过 reactor I/O 线程发送数据

    tcp_connection_send_buffer(tcpConnection, output);

    return

样例程序结果

我们启动这个服务器端程序，你可以从服务器端的输出上看到使用了 poll 作为事件分发方式。

多打开几个 telnet 客户端交互，main-thread 只负责新的连接建立，每个客户端数据的收发由不同的子线程 Thread-1、Thread-2、Thread-3 和 Thread-4 来提供服务。

这里由于使用了子线程进行 I/O 处理，主线程可以专注于新连接处理，从而大大提高了客户端连接成功率。

$./poll-server-multithreads

[msg] set poll as dispatcher

[msg] add channel fd == 4, main thread

[msg] poll added channel fd==4

[msg] set poll as dispatcher

[msg] add channel fd == 7, main thread

[msg] poll added channel fd==7

[msg] event loop thread init and signal, Thread-1

[msg] event loop run, Thread-1

[msg] event loop thread started, Thread-1

[msg] set poll as dispatcher

[msg] add channel fd == 9, main thread

[msg] poll added channel fd==9

[msg] event loop thread init and signal, Thread-2

[msg] event loop run, Thread-2

[msg] event loop thread started, Thread-2

[msg] set poll as dispatcher

[msg] add channel fd == 11, main thread

[msg] poll added channel fd==11

[msg] event loop thread init and signal, Thread-3

[msg] event loop thread started, Thread-3

[msg] set poll as dispatcher

[msg] event loop run, Thread-3

[msg] add channel fd == 13, main thread

[msg] poll added channel fd==13

[msg] event loop thread init and signal, Thread-4

[msg] event loop run, Thread-4

[msg] event loop thread started, Thread-4

[msg] add channel fd == 5, main thread

[msg] poll added channel fd==5

[msg] event loop run, main thread

[msg] get message channel i==1, fd==5

[msg] activate channel fd == 5, revents=2, main thread

[msg] new connection established, socket == 14

connection completed

[msg] get message channel i==0, fd==7

[msg] activate channel fd == 7, revents=2, Thread-1

[msg] wakeup, Thread-1

[msg] add channel fd == 14, Thread-1

[msg] poll added channel fd==14

[msg] get message channel i==1, fd==14

[msg] activate channel fd == 14, revents=2, Thread-1

get message from tcp connection connection-14

fasfas

[msg] get message channel i==1, fd==14

[msg] activate channel fd == 14, revents=2, Thread-1

get message from tcp connection connection-14

fasfas

asfa

[msg] get message channel i==1, fd==5

[msg] activate channel fd == 5, revents=2, main thread

[msg] new connection established, socket == 15

connection completed

[msg] get message channel i==0, fd==9

[msg] activate channel fd == 9, revents=2, Thread-2

[msg] wakeup, Thread-2

[msg] add channel fd == 15, Thread-2

[msg] poll added channel fd==15

[msg] get message channel i==1, fd==15

[msg] activate channel fd == 15, revents=2, Thread-2

get message from tcp connection connection-15

afasdfasf

[msg] get message channel i==1, fd==15

[msg] activate channel fd == 15, revents=2, Thread-2

get message from tcp connection connection-15

afasdfasf

safsafa

[msg] get message channel i==1, fd==15

[msg] activate channel fd == 15, revents=2, Thread-2

[msg] poll delete channel fd==15

connection closed

[msg] get message channel i==1, fd==5

[msg] activate channel fd == 5, revents=2, main thread

[msg] new connection established, socket == 16

connection completed

[msg] get message channel i==0, fd==11

[msg] activate channel fd == 11, revents=2, Thread-3

[msg] wakeup, Thread-3

[msg] add channel fd == 16, Thread-3

[msg] poll added channel fd==16

[msg] get message channel i==1, fd==16

[msg] activate channel fd == 16, revents=2, Thread-3

get message from tcp connection connection-16

fdasfasdf

[msg] get message channel i==1, fd==14

[msg] activate channel fd == 14, revents=2, Thread-1

[msg] poll delete channel fd==14

connection closed

[msg] get message channel i==1, fd==5

[msg] activate channel fd == 5, revents=2, main thread

[msg] new connection established, socket == 17

connection completed

[msg] get message channel i==0, fd==13

[msg] activate channel fd == 13, revents=2, Thread-4

[msg] wakeup, Thread-4

[msg] add channel fd == 17, Thread-4

[msg] poll added channel fd==17

[msg] get message channel i==1, fd==17

[msg] activate channel fd == 17, revents=2, Thread-4

get message from tcp connection connection-17

qreqwrq

[msg] get message channel i==1, fd==16

[msg] activate channel fd == 16, revents=2, Thread-3

[msg] poll delete channel fd==16

connection closed

[msg] get message channel i==1, fd==5

[msg] activate channel fd == 5, revents=2, main thread

[msg] new connection established, socket == 18

connection completed

[msg] get message channel i==0, fd==7

[msg] activate channel fd == 7, revents=2, Thread-1

[msg] wakeup, Thread-1

[msg] add channel fd == 18, Thread-1

[msg] poll added channel fd==18

[msg] get message channel i==1, fd==18

[msg] activate channel fd == 18, revents=2, Thread-1

get message from tcp connection connection-18

fasgasdg

^C

总结

本讲主要讲述了主从 reactor 模式，主从 reactor 模式中，主 reactor 只负责连接建立的处理，而把已连接套接字的 I/O 事件分发交给从 reactor 线程处理，这大大提高了客户端连接的处理能力。从 Netty 的实现上来看，也遵循了这一原则。

思考题

和往常一样，给大家留两道思考题：

第一道，从日志输出中，你还可以看到 main-thread 首先加入了 fd 为 4 的套接字，这个是监听套接字，很好理解。可是这里的 main-thread 又加入了一个 fd 为 7 的套接字，这个套集字是干什么用的呢？

第二道，你可以试着修改一下服务器端的代码，把 decode-compute-encode 部分使用线程或者线程池来处理。

欢迎你在评论区写下你的思考，或者在 GitHub 上上传修改过的代码，我会和你一起交流，也欢迎把这篇文章分享给你的朋友或者同事，一起交流一下。

29 | 渐入佳境：使用epoll和多线程模型

你好，我是盛延敏，这里是网络编程实战第 29 讲，欢迎回来。

在前面的第 27 讲和第 28 讲中，我介绍了基于 poll 事件分发的 reactor 反应堆模式，以及主从反应堆模式。我们知道，和 poll 相比，Linux 提供的 epoll 是一种更为高效的事件分发机制。在这一讲里，我们将切换到 epoll 实现的主从反应堆模式，并且分析一下为什么 epoll 的性能会强于 poll 等传统的事件分发机制。

如何切换到 epoll

我已经将所有的代码已经放置到GitHub上，你可以自行查看或下载。

我们的网络编程框架是可以同时支持 poll 和 epoll 机制的，那么如何开启 epoll 的支持呢？

lib/event_loop.c 文件的 event_loop_init_with_name 函数是关键，可以看到，这里是通过宏 EPOLL_ENABLE 来决定是使用 epoll 还是 poll 的。

struct event_loop *event_loop_init_with_name(char *thread_name) {

  ...

#ifdef EPOLL_ENABLE

    yolanda_msgx("set epoll as dispatcher, %s", eventLoop->thread_name);

    eventLoop->eventDispatcher = &epoll_dispatcher;

#else

    yolanda_msgx("set poll as dispatcher, %s", eventLoop->thread_name);

    eventLoop->eventDispatcher = &poll_dispatcher;

#endif

    eventLoop->event_dispatcher_data = eventLoop->eventDispatcher->init(eventLoop);

    ...

}

在根目录下的 CMakeLists.txt 文件里，引入 CheckSymbolExists，如果系统里有 epoll_create 函数和 sys/epoll.h，就自动开启 EPOLL_ENABLE。如果没有，EPOLL_ENABLE 就不会开启，自动使用 poll 作为默认的事件分发机制。

# check epoll and add config.h for the macro compilation

include(CheckSymbolExists)

check_symbol_exists(epoll_create "sys/epoll.h" EPOLL_EXISTS)

if (EPOLL_EXISTS)

    #    Linux 下设置为 epoll

    set(EPOLL_ENABLE 1 CACHE INTERNAL "enable epoll")

    #    Linux 下也设置为 poll

    #    set(EPOLL_ENABLE "" CACHE INTERNAL "not enable epoll")

else ()

    set(EPOLL_ENABLE "" CACHE INTERNAL "not enable epoll")

endif ()

但是，为了能让编译器使用到这个宏，需要让 CMake 往 config.h 文件里写入这个宏的最终值，configure_file 命令就是起这个作用的。其中 config.h.cmake 是一个模板文件，已经预先创建在根目录下。同时还需要让编译器 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 讲和28 讲中的程序案例如何改为使用 poll 的呢？

我们可以修改 CMakeLists.txt 文件，把 Linux 下设置为 poll 的那段注释下的命令打开，同时关闭掉原先设置为 1 的命令就可以了。 下面就是具体的示例代码。

# check epoll and add config.h for the macro compilation

include(CheckSymbolExists)

check_symbol_exists(epoll_create "sys/epoll.h" EPOLL_EXISTS)

if (EPOLL_EXISTS)

    #    Linux 下也设置为 poll

     set(EPOLL_ENABLE "" CACHE INTERNAL "not enable epoll")

else ()

    set(EPOLL_ENABLE "" CACHE INTERNAL "not enable epoll")

endif (

不管怎样，现在我们得到了一个 Linux 下使用 epoll 作为事件分发的版本，现在让我们使用它来编写程序吧。

样例程序

我们的样例程序和第 28 讲的一模一样，只是现在我们的事件分发机制从 poll 切换到了 epoll。

#include <lib/acceptor.h>

#include "lib/common.h"

#include "lib/event_loop.h"

#include "lib/tcp_server.h"

char rot13_char(char c) {

    if ((c >= 'a' && c <= 'm') || (c >= 'A' && c <= 'M'))

        return c + 13;

    else if ((c >= 'n' && c <= 'z') || (c >= 'N' && c <= 'Z'))

        return c - 13;

    else

        return c;

}

// 连接建立之后的 callback

int onConnectionCompleted(struct tcp_connection *tcpConnection) {

    printf("connection completed\n");

    return 0;

}

// 数据读到 buffer 之后的 callback

int onMessage(struct buffer *input, struct tcp_connection *tcpConnection) {

    printf("get message from tcp connection %s\n", tcpConnection->name);

    printf("%s", input->data);

    struct buffer *output = buffer_new();

    int size = buffer_readable_size(input);

    for (int i = 0; i < size; i++) {

        buffer_append_char(output, rot13_char(buffer_read_char(input)));

    }

    tcp_connection_send_buffer(tcpConnection, output);

    return 0;

}

// 数据通过 buffer 写完之后的 callback

int onWriteCompleted(struct tcp_connection *tcpConnection) {

    printf("write completed\n");

    return 0;

}

// 连接关闭之后的 callback

int onConnectionClosed(struct tcp_connection *tcpConnection) {

    printf("connection closed\n");

    return 0;

}

int main(int c, char **v) {

    // 主线程 event_loop

    struct event_loop *eventLoop = event_loop_init();

    // 初始化 acceptor

    struct acceptor *acceptor = acceptor_init(SERV_PORT);

    // 初始 tcp_server，可以指定线程数目，这里线程是 4，说明是一个 acceptor 线程，4 个 I/O 线程，没一个 I/O 线程

    //tcp_server 自己带一个 event_loop

    struct TCPserver *tcpServer = tcp_server_init(eventLoop, acceptor, onConnectionCompleted, onMessage,

                                                  onWriteCompleted, onConnectionClosed, 4);

    tcp_server_start(tcpServer);

    // main thread for acceptor

    event_loop_run(eventLoop);

}

关于这个程序，之前一直没有讲到的部分是缓冲区对象 buffer。这其实也是网络编程框架应该考虑的部分。

我们希望框架可以对应用程序封装掉套接字读和写的部分，转而提供的是针对缓冲区对象的读和写操作。这样一来，从套接字收取数据、处理异常、发送数据等操作都被类似 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 主要处理的是已连接套接字的读写事件。文稿中的这幅图详细解释了这部分逻辑。

epoll 的性能分析

epoll 的性能凭什么就要比 poll 或者 select 好呢？这要从两个角度来说明。

第一个角度是事件集合。在每次使用 poll 或 select 之前，都需要准备一个感兴趣的事件集合，系统内核拿到事件集合，进行分析并在内核空间构建相应的数据结构来完成对事件集合的注册。而 epoll 则不是这样，epoll 维护了一个全局的事件集合，通过 epoll 句柄，可以操纵这个事件集合，增加、删除或修改这个事件集合里的某个元素。要知道在绝大多数情况下，事件集合的变化没有那么的大，这样操纵系统内核就不需要每次重新扫描事件集合，构建内核空间数据结构。

第二个角度是就绪列表。每次在使用 poll 或者 select 之后，应用程序都需要扫描整个感兴趣的事件集合，从中找出真正活动的事件，这个列表如果增长到 10K 以上，每次扫描的时间损耗也是惊人的。事实上，很多情况下扫描完一圈，可能发现只有几个真正活动的事件。而 epoll 则不是这样，epoll 返回的直接就是活动的事件列表，应用程序减少了大量的扫描时间。

此外， epoll 还提供了更高级的能力——边缘触发。第 23 讲通过一个直观的例子，讲解了边缘触发和条件触发的区别。

这里再举一个例子说明一下。

如果某个套接字有 100 个字节可以读，边缘触发和条件触发都会产生 read ready notification 事件，如果应用程序只读取了 50 个字节，边缘触发就会陷入等待；而条件触发则会因为还有 50 个字节没有读取完，不断地产生 read ready notification 事件。

在边缘触发下，如果某个套接字缓冲区可以写，会无限次返回 write ready notification 事件，在这种情况下，如果应用程序没有准备好，不需要发送数据，一定需要解除套接字上的 ready notification 事件，否则 CPU 就直接跪了。

我们简单地总结一下，边缘触发只会产生一次活动事件，性能和效率更高。不过，程序处理起来要更为小心。

总结

本讲我们将程序框架切换到了 epoll 的版本，和 poll 版本相比，只是底层的框架做了更改，上层应用程序不用做任何修改，这也是程序框架强大的地方。和 poll 相比，epoll 从事件集合和就绪列表两个方面加强了程序性能，是 Linux 下高性能网络程序的首选。

思考题

最后我给大家给大家布置两道思考题：

第一道，说说你对边缘触发和条件触发的理解。

第二道，对于边缘触发和条件触发，onMessage 函数处理要注意什么？

欢迎你在评论区写下你的思考，也欢迎把这篇文章分享给你的朋友或者同事，一起交流进步。

30 | 真正的大杀器：异步I/O探索

你好，我是盛延敏，这里是网络编程实战的第 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 请求调用才返回。接下来，应用程序就可以对缓冲区的数据进行数据解析。

第二种是非阻塞 I/O。非阻塞的 read 请求在数据未准备好的情况下立即返回，应用程序可以不断轮询内核，直到数据准备好，内核将数据拷贝到应用程序缓冲，并完成这次 read 调用。注意，这里最后一次 read 调用，获取数据的过程，是一个同步的过程。这里的同步指的是内核区域的数据拷贝到缓存区这个过程。

每次让应用程序去轮询内核的 I/O 是否准备好，是一个不经济的做法，因为在轮询的过程中应用进程啥也不能干。于是，像 select、poll 这样的 I/O 多路复用技术就隆重登场了。通过 I/O 事件分发，当内核数据准备好时，再通知应用程序进行操作。这个做法大大改善了应用进程对 CPU 的利用率，在没有被通知的情况下，应用进程可以使用 CPU 做其他的事情。

注意，这里 read 调用，获取数据的过程，也是一个同步的过程。

第一种阻塞 I/O 我想你已经比较了解了，在阻塞 I/O 的情况下，应用程序会被挂起，直到获取数据。第二种非阻塞 I/O 和第三种基于非阻塞 I/O 的多路复用技术，获取数据的操作不会被阻塞。

无论是第一种阻塞 I/O，还是第二种非阻塞 I/O，第三种基于非阻塞 I/O 的多路复用都是同步调用技术。为什么这么说呢？因为同步调用、异步调用的说法，是对于获取数据的过程而言的，前面几种最后获取数据的 read 操作调用，都是同步的，在 read 调用时，内核将数据从内核空间拷贝到应用程序空间，这个过程是在 read 函数中同步进行的，如果内核实现的拷贝效率很差，read 调用就会在这个同步过程中消耗比较长的时间。

而真正的异步调用则不用担心这个问题，我们接下来就来介绍第四种 I/O 技术，当我们发起 aio_read 之后，就立即返回，内核自动将数据从内核空间拷贝到应用程序空间，这个拷贝过程是异步的，内核自动完成的，和前面的同步操作不一样，应用程序并不需要主动发起拷贝动作。

还记得第 22讲中讲到的去书店买书的例子吗? 基于这个例子，针对以上的场景，我们可以这么理解。

第一种阻塞 I/O 就是你去了书店，告诉老板你想要某本书，然后你就一直在那里等着，直到书店老板翻箱倒柜找到你想要的书。

第二种非阻塞 I/O 类似于你去了书店，问老板有没有一本书，老板告诉你没有，你就离开了。一周以后，你又来这个书店，再问这个老板，老板一查，有了，于是你买了这本书。

第三种基于非阻塞的 I/O 多路复用，你来到书店告诉老板：“老板，到货给我打电话吧，我再来付钱取书。”

第四种异步 I/O 就是你连去书店取书的过程也想省了，你留下地址，付了书费，让老板到货时寄给你，你直接在家里拿到就可以看了。

这里放置了一张表格，总结了以上几种 I/O 模型。

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 文件，可以看到，这个文件成功写入了我们期望的数据。

请注意，以上的读写，都不需要我们在应用程序里再发起调用，系统内核直接帮我们做好了。

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 服务请求的例子来说明：

客户端发起一个 GET 请求；

这个 GET 请求对应的字节流被内核读取完成，内核将这个完成事件放置到一个队列中；

event loop 线程，也就是 Poractor 从这个队列里获取事件，根据事件类型，分发到不同的处理函数上，比如一个 http handle 的 onMessage 解析函数；

HTTP request 解析函数完成报文解析；

业务逻辑处理，比如读取数据库的记录；

业务逻辑处理完成，开始 encode，完成之后，发起一个异步写操作；

这个异步写操作被内核执行，完成之后这个异步写操作被放置到内核的队列中；

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 下高性能网络程序设计。

思考题

和往常一样，给大家布置两道思考题：

你可以查一查 Linux 的资料，看看为了在内核层面支持完全的异步 I/O，Linux 的世界里都发生了什么？

在例子程序里，aio_error 一直处于占用 CPU 轮询异步操作的状态，有没有别的方法可以改进一下，比如挂起调用者、设置超时时间等？

欢迎你在评论区写下你的思考，也欢迎把这篇文章分享给你的朋友或者同事，一起交流进步一下。

31丨性能篇答疑--epoll源码深度剖析

你好，我是盛延敏，今天是网络编程实战性能篇的答疑模块，欢迎回来。

在性能篇中，我主要围绕 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_quue_entry_t，根据 wait_quue_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 讲里介绍 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 时间被调度器调度给其他进程使用，当然，当前进程可能会被唤醒，唤醒的条件包括有以下四种：

当前进程超时；

当前进程收到一个 signal 信号；

某个描述字上有事件发生；

当前进程被 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 讲吗，在这种情况下，如果套接字不是非阻塞的，整个进程将会被阻塞，这也是为什么将非阻塞套接字配合 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

在前面的文章里，我们一直都在强调 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 下可以享受高性能网络服务器带来的种种技术红利。

32 | 自己动手写高性能HTTP服务器（一）：设计和思路

你好，我是盛延敏，这里是网络编程实战第 32 讲，欢迎回来。

从这一讲开始，我们进入实战篇，开启一个高性能 HTTP 服务器的编写之旅。

在开始编写高性能 HTTP 服务器之前，我们先要构建一个支持 TCP 的高性能网络编程框架，完成这个 TCP 高性能网络框架之后，再增加 HTTP 特性的支持就比较容易了，这样就可以很快开发出一个高性能的 HTTP 服务器程序。

设计需求

在第三个模块性能篇中，我们已经使用这个网络编程框架完成了多个应用程序的开发，这也等于对这个网络编程框架提出了编程接口方面的需求，综合之前的使用经验，这个 TCP 高性能网络框架需要满足的需求有以下三点。

第一，采用 reactor 模型，可以灵活使用 poll/epoll 作为事件分发实现。

第二，必须支持多线程，从而可以支持单线程单 reactor 模式，也可以支持多线程主 - 从 reactor 模式。可以将套接字上的 I/O 事件分离到多个线程上。

第三，封装读写操作到 Buffer 对象中。

按照这三个需求，正好可以把整体设计思路分成三块来讲解，分别包括反应堆模式设计、I/O 模型和多线程模型设计、数据读写封装和 buffer。今天我们主要讲一下主要的设计思路和数据结构，以及反应堆模式设计。

主要设计思路

反应堆模式设计

反应堆模式，按照性能篇的讲解，主要是设计一个基于事件分发和回调的反应堆框架。这个框架里面的主要对象包括：

event_loop

你可以把 event_loop 这个对象理解成和一个线程绑定的无限事件循环，你会在各种语言里看到 event_loop 这个抽象。这是什么意思呢？简单来说，它就是一个无限循环着的事件分发器，一旦有事件发生，它就会回调预先定义好的回调函数，完成事件的处理。

具体来说，event_loop 使用 poll 或者 epoll 方法将一个线程阻塞，等待各种 I/O 事件的发生。

channel

对各种注册到 event_loop 上的对象，我们抽象成 channel 来表示，例如注册到 event_loop 上的监听事件，注册到 event_loop 上的套接字读写事件等。在各种语言的 API 里，你都会看到 channel 这个对象，大体上它们表达的意思跟我们这里的设计思路是比较一致的。

acceptor

acceptor 对象表示的是服务器端监听器，acceptor 对象最终会作为一个 channel 对象，注册到 event_loop 上，以便进行连接完成的事件分发和检测。

event_dispatcher

event_dispatcher 是对事件分发机制的一种抽象，也就是说，可以实现一个基于 poll 的 poll_dispatcher，也可以实现一个基于 epoll 的 epoll_dispatcher。在这里，我们统一设计一个 event_dispatcher 结构体，来抽象这些行为。

channel_map

channel_map 保存了描述字到 channel 的映射，这样就可以在事件发生时，根据事件类型对应的套接字快速找到 chanel 对象里的事件处理函数。

I/O 模型和多线程模型设计

I/O 线程和多线程模型，主要解决 event_loop 的线程运行问题，以及事件分发和回调的线程执行问题。

thread_pool

thread_pool 维护了一个 sub-reactor 的线程列表，它可以提供给主 reactor 线程使用，每次当有新的连接建立时，可以从 thread_pool 里获取一个线程，以便用它来完成对新连接套接字的 read/write 事件注册，将 I/O 线程和主 reactor 线程分离。

event_loop_thread

event_loop_thread 是 reactor 的线程实现，连接套接字的 read/write 事件检测都是在这个线程里完成的。

Buffer 和数据读写

buffer

buffer 对象屏蔽了对套接字进行的写和读的操作，如果没有 buffer 对象，连接套接字的 read/write 事件都需要和字节流直接打交道，这显然是不友好的。所以，我们也提供了一个基本的 buffer 对象，用来表示从连接套接字收取的数据，以及应用程序即将需要发送出去的数据。

tcp_connection

tcp_connection 这个对象描述的是已建立的 TCP 连接。它的属性包括接收缓冲区、发送缓冲区、channel 对象等。这些都是一个 TCP 连接的天然属性。

tcp_connection 是大部分应用程序和我们的高性能框架直接打交道的数据结构。我们不想把最下层的 channel 对象暴露给应用程序，因为抽象的 channel 对象不仅仅可以表示 tcp_connection，前面提到的监听套接字也是一个 channel 对象，后面提到的唤醒 socketpair 也是一个 channel 对象。所以，我们设计了 tcp_connection 这个对象，希望可以提供给用户比较清晰的编程入口。

反应堆模式设计

概述

下面，我们详细讲解一下以 event_loop 为核心的反应堆模式设计。我在文稿里放置了一张 event_loop 的运行详图，你可以对照这张图来理解。

当 event_loop_run 完成之后，线程进入循环，首先执行 dispatch 事件分发，一旦有事件发生，就会调用 channel_event_activate 函数，在这个函数中完成事件回调函数 eventReadcallback 和 eventWritecallback 的调用，最后再进行 event_loop_handle_pending_channel，用来修改当前监听的事件列表，完成这个部分之后，又进入了事件分发循环。

event_loop 分析

说 event_loop 是整个反应堆模式设计的核心，一点也不为过。先看一下 event_loop 的数据结构。

在这个数据结构中，最重要的莫过于 event_dispatcher 对象了。你可以简单地把 event_dispatcher 理解为 poll 或者 epoll，它可以让我们的线程挂起，等待事件的发生。

这里有一个小技巧，就是 event_dispatcher_data，它被定义为一个 void * 类型，可以按照我们的需求，任意放置一个我们需要的对象指针。这样，针对不同的实现，例如 poll 或者 epoll，都可以根据需求，放置不同的数据对象。

event_loop 中还保留了几个跟多线程有关的对象，如 owner_thread_id 是保留了每个 event loop 的线程 ID，mutex 和 con 是用来进行线程同步的。

socketPair 是父线程用来通知子线程有新的事件需要处理。pending_head 和 pending_tail 是保留在子线程内的需要处理的新的事件。

struct event_loop {

    int quit;

    const struct event_dispatcher *eventDispatcher;

    /** 对应的 event_dispatcher 的数据. */

    void *event_dispatcher_data;

    struct channel_map *channelMap;

    int is_handle_pending;

    struct channel_element *pending_head;

    struct channel_element *pending_tail;

    pthread_t owner_thread_id;

    pthread_mutex_t mutex;

    pthread_cond_t cond;

    int socketPair[2];

    char *thread_name;

};

下面我们看一下 event_loop 最主要的方法 event_loop_run 方法，前面提到过，event_loop 就是一个无限 while 循环，不断地在分发事件。

/**

 *

 * 1. 参数验证

 * 2. 调用 dispatcher 来进行事件分发, 分发完回调事件处理函数

 */

int event_loop_run(struct event_loop *eventLoop) {

    assert(eventLoop != NULL);

    struct event_dispatcher *dispatcher = eventLoop->eventDispatcher;

    if (eventLoop->owner_thread_id != pthread_self()) {

        exit(1);

    }

    yolanda_msgx("event loop run, %s", eventLoop->thread_name);

    struct timeval timeval;

    timeval.tv_sec = 1;

    while (!eventLoop->quit) {

        //block here to wait I/O event, and get active channels

        dispatcher->dispatch(eventLoop, &timeval);

        //handle the pending channel

        event_loop_handle_pending_channel(eventLoop);

    }

    yolanda_msgx("event loop end, %s", eventLoop->thread_name);

    return 0;

}

代码很明显地反映了这一点，这里我们在 event_loop 不退出的情况下，一直在循环，循环体中调用了 dispatcher 对象的 dispatch 方法来等待事件的发生。

event_dispacher 分析

为了实现不同的事件分发机制，这里把 poll、epoll 等抽象成了一个 event_dispatcher 结构。event_dispatcher 的具体实现有 poll_dispatcher 和 epoll_dispatcher 两种，实现的方法和性能篇21讲和22 讲类似，这里就不再赘述，你如果有兴趣的话，可以直接研读代码。

/** 抽象的 event_dispatcher 结构体，对应的实现如 select,poll,epoll 等 I/O 复用. */

struct event_dispatcher {

    /**  对应实现 */

    const char *name;

    /**  初始化函数 */

    void *(*init)(struct event_loop * eventLoop);

    /** 通知 dispatcher 新增一个 channel 事件 */

    int (*add)(struct event_loop * eventLoop, struct channel * channel);

    /** 通知 dispatcher 删除一个 channel 事件 */

    int (*del)(struct event_loop * eventLoop, struct channel * channel);

    /** 通知 dispatcher 更新 channel 对应的事件 */

    int (*update)(struct event_loop * eventLoop, struct channel * channel);

    /** 实现事件分发，然后调用 event_loop 的 event_activate 方法执行 callback*/

    int (*dispatch)(struct event_loop * eventLoop, struct timeval *);

    /** 清除数据 */

    void (*clear)(struct event_loop * eventLoop);

};

channel 对象分析

channel 对象是用来和 event_dispather 进行交互的最主要的结构体，它抽象了事件分发。一个 channel 对应一个描述字，描述字上可以有 READ 可读事件，也可以有 WRITE 可写事件。channel 对象绑定了事件处理函数 event_read_callback 和 event_write_callback。

typedef int (*event_read_callback)(void *data);

typedef int (*event_write_callback)(void *data);

struct channel {

    int fd;

    int events;   // 表示 event 类型

    event_read_callback eventReadCallback;

    event_write_callback eventWriteCallback;

    void *data; //callback data, 可能是 event_loop，也可能是 tcp_server 或者 tcp_connection

};

channel_map 对象分析

event_dispatcher 在获得活动事件列表之后，需要通过文件描述字找到对应的 channel，从而回调 channel 上的事件处理函数 event_read_callback 和 event_write_callback，为此，设计了 channel_map 对象。

/**

 * channel 映射表, key 为对应的 socket 描述字

 */

struct channel_map {

    void **entries;

    /* The number of entries available in entries */

    int nentries;

};

channel_map 对象是一个数组，数组的下标即为描述字，数组的元素为 channel 对象的地址。

比如描述字 3 对应的 channel，就可以这样直接得到。

struct chanenl * channel = map->entries[3];

这样，当 event_dispatcher 需要回调 chanel 上的读、写函数时，调用 channel_event_activate 就可以，下面是 channel_event_activate 的实现，在找到了对应的 channel 对象之后，根据事件类型，回调了读函数或者写函数。注意，这里使用了 EVENT_READ 和 EVENT_WRITE 来抽象了 poll 和 epoll 的所有读写事件类型。

int channel_event_activate(struct event_loop *eventLoop, int fd, int revents) {

    struct channel_map *map = eventLoop->channelMap;

    yolanda_msgx("activate channel fd == %d, revents=%d, %s", fd, revents, eventLoop->thread_name);

    if (fd < 0)

        return 0;

    if (fd >= map->nentries)return (-1);

    struct channel *channel = map->entries[fd];

    assert(fd == channel->fd);

    if (revents & (EVENT_READ)) {

        if (channel->eventReadCallback) channel->eventReadCallback(channel->data);

    }

    if (revents & (EVENT_WRITE)) {

        if (channel->eventWriteCallback) channel->eventWriteCallback(channel->data);

    }

    return 0;

}

增加、删除、修改 channel event

那么如何增加新的 channel event 事件呢？这几个函数是用来增加、删除和修改 channel event 事件的。

int event_loop_add_channel_event(struct event_loop *eventLoop, int fd, struct channel *channel1);

int event_loop_remove_channel_event(struct event_loop *eventLoop, int fd, struct channel *channel1);

int event_loop_update_channel_event(struct event_loop *eventLoop, int fd, struct channel *channel1);

前面三个函数提供了入口能力，而真正的实现则落在这三个函数上：

int event_loop_handle_pending_add(struct event_loop *eventLoop, int fd, struct channel *channel);

int event_loop_handle_pending_remove(struct event_loop *eventLoop, int fd, struct channel *channel);

int event_loop_handle_pending_update(struct event_loop *eventLoop, int fd, struct channel *channel);

我们看一下其中的一个实现，event_loop_handle_pendign_add 在当前 event_loop 的 channel_map 里增加一个新的 key-value 对，key 是文件描述字，value 是 channel 对象的地址。之后调用 event_dispatcher 对象的 add 方法增加 channel event 事件。注意这个方法总在当前的 I/O 线程中执行。

// in the i/o thread

int event_loop_handle_pending_add(struct event_loop *eventLoop, int fd, struct channel *channel) {

    yolanda_msgx("add channel fd == %d, %s", fd, eventLoop->thread_name);

    struct channel_map *map = eventLoop->channelMap;

    if (fd < 0)

        return 0;

    if (fd >= map->nentries) {

        if (map_make_space(map, fd, sizeof(struct channel *)) == -1)

            return (-1);

    }

    // 第一次创建，增加

    if ((map)->entries[fd] == NULL) {

        map->entries[fd] = calloc(1, sizeof(struct channel *));

        map->entries[fd] = channel;

        //add channel

        struct event_dispatcher *eventDispatcher = eventLoop->eventDispatcher;

        eventDispatcher->add(eventLoop, channel);

        return 1;

    }

    return 0;

}

总结

在这一讲里，我们介绍了高性能网络编程框架的主要设计思路和基本数据结构，以及反应堆设计相关的具体做法。在接下来的章节中，我们将继续编写高性能网络编程框架的线程模型以及读写 Buffer 部分。

思考题

和往常一样，给你留两道思考题:

第一道，如果你有兴趣，不妨实现一个 select_dispatcher 对象，用 select 方法实现定义好的 event_dispatcher 接口；

第二道，仔细研读 channel_map 实现中的 map_make_space 部分，说说你的理解。

欢迎你在评论区写下你的思考，也欢迎把这篇文章分享给你的朋友或者同事，一起交流一下。

33 | 自己动手写高性能HTTP服务器（二）：I/O模型和多线程模型实现

你好，我是盛延敏，这里是网络编程实战第 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。

文稿中放置了一张图，阐述了线程的运行关系。

为了方便你理解，我把对应的函数实现列在了另外一张图中。

主线程等待多个 sub-reactor 子线程初始化完

主线程需要等待子线程完成初始化，也就是需要获取子线程对应数据的反馈，而子线程初始化也是对这部分数据进行初始化，实际上这是一个多线程的通知问题。采用的做法在前面讲多线程的时候也提到过，使用 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[0], 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 的算法，你觉得有没有改进的空间？如果改进的话，你可能会怎么做？

欢迎在评论区写下你的思考，也欢迎把这篇文章分享给你的朋友或者同事，一起交流进步一下。

34 | 自己动手写高性能HTTP服务器（三）：TCP字节流处理和HTTP协议实现

你好，我是盛延敏，这里是网络编程实战第 34 讲，欢迎回来。

这一讲，我们延续第 33 讲的话题，继续解析高性能网络编程框架的字节流处理部分，并为网络编程框架增加 HTTP 相关的功能，在此基础上完成 HTTP 高性能服务器的编写。

buffer 对象

你肯定在各种语言、各种框架里面看到过不同的 buffer 对象，buffer，顾名思义，就是一个缓冲区对象，缓存了从套接字接收来的数据以及需要发往套接字的数据。

如果是从套接字接收来的数据，事件处理回调函数在不断地往 buffer 对象增加数据，同时，应用程序需要不断把 buffer 对象中的数据处理掉，这样，buffer 对象才可以空出新的位置容纳更多的数据。

如果是发往套接字的数据，应用程序不断地往 buffer 对象增加数据，同时，事件处理回调函数不断调用套接字上的发送函数将数据发送出去，减少 buffer 对象中的写入数据。

可见，buffer 对象是同时可以作为输入缓冲（input buffer）和输出缓冲（output buffer）两个方向使用的，只不过，在两种情形下，写入和读出的对象是有区别的。

我在文稿中给出了一张图，描述了 buffer 对象的设计。

下面是 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 函数就是起这个作用的，如果右边绿色的连续空间不足以容纳新的数据，而最左边灰色部分加上右边绿色部分一起可以容纳下新数据，就会触发这样的移动拷贝，最终红色部分占据了最左边，绿色部分占据了右边，右边绿色的部分成为一个连续的可吸写入空间，就可以容纳下新的数据。下面的一张图解释了这个过程。

下面是 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 函数来完成缓冲区的扩容。

下面这张图对此做了解释。

套接字接收数据处理

套接字接收数据是在 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 讲中讲到的 HTTP 协议吗？我们从 16 讲得知，HTTP 通过设置回车符、换行符做为 HTTP 报文协议的边界。

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 服务器例子就变得非常简单。你可以在文稿中，以及 GitHub 仓库中看到这个例子。

在这个例子中，最主要的部分是 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>%

总结

这一讲我们主要讲述了整个编程框架的字节流处理能力，引入了 buffer 对象，并在此基础上通过增加 HTTP 的特性，包括 http_server、http_request、http_response，完成了 HTTP 高性能服务器的编写。实例程序利用框架提供的能力，编写了一个简单的 HTTP 服务器程序。

思考题

和往常一样，给大家布置两道思考题：

第一道， 你可以试着在 HTTP 服务器中增加 MIME 的处理能力，当用户请求 /photo 路径时，返回一张图片。

第二道，在我们的开发中，已经有很多面向对象的设计，你可以仔细研读代码，说说你对这部分的理解。

欢迎你在评论区写下你的思考，也欢迎把这篇文章分享给你的朋友或者同事，一起交流一下。

35 | 答疑：编写高性能网络编程框架时，都需要注意哪些问题？

你好，我是盛延敏，这里是网络编程实战的第 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 的设计。

而且，我们的 channel_map 还不会太占用内存，在最开始的时候，整个 channel_map 的指针数组大小为 0，当这个 channel_map 投入使用时，会根据实际使用的套接字的增长，按照 32、64、128 这样的速度成倍增长，这样既保证了实际的需求，也不会一下子占用太多的内存。

此外，当指针数组增长时，我们不会销毁原来的部分，而是使用 realloc() 把旧的内容搬过去，再使用 memset() 用来给新申请的内存初始化为 0 值，这样既高效也节省内存。

总结

以上就是实战篇中一些同学的疑问。

在这篇文章之后，我们的专栏就告一段落了，我希望这个专栏可以帮你梳理清楚高性能网络编程的方方面面，如果你能从中有所领悟，或者帮助你在面试中拿到好的结果，我会深感欣慰。

如果你觉得今天的答疑内容对你有所帮助，欢迎把它转发给你的朋友或者同事，一起交流一下。

结束语丨我相信这不是结束，让我们江湖再见

这是“网络编程实战”的最后一篇文章了，提起笔来，竟然感觉到有些不舍，回想起这个专栏的诞生，和总编、编辑反复地讨论选题，根据用户反馈不断打磨大纲，一幕幕都感觉恍如昨日。时间过得飞快，感谢大家几个月以来的相伴，不管大家对这个专栏有什么样的评价，我都可以自豪地说，我是很用心地在做这个事情。感谢极客时间的总编郭蕾，还有这个专栏的编辑，没有你们的支持，很难想象我会坚持完成这个专栏。

虽然我自认是个高性能网络编程领域的老手，但是在此之前，整个知识体系都是碎片化的，实践中虽然有过一些经验和教训，却很难形成一个完整的系列。平时也读过不少网络编程方面的书，大多也是为了解决实践问题进行的查疑解惑，从来没有动力去把这些碎片化的知识加以整理，所以，也非常感谢极客时间的这次邀约，使我能够以一个更加系统的角度把自己之前碰到的问题结合理论加以梳理，最终能形成一个比较完整的系列。我感觉自己在做这个事情的过程中，也收获颇丰。

学习方法论

谈完自己的感受，作为这个专栏的结束语，我还是非常希望能给坚持学下来的你们，我的忠实朋友们谈一谈学习方法论。

动手实践

前两天和同事们一起去登山，有些同事身体很棒，一溜小跑就奔到了山顶。我在下面远远地看着他们，无比羡慕，萌生出一个想法，如果能不登攀这些石阶直达到山顶，那该多好啊。

可是，这终归是一个不切实际的的幻想，我知道我还是要一步一个台阶地迈过这些石阶，才能达到山顶。当我最终到达山顶时，那些疲惫和羡慕都随风而去，自己所能感受到的，则是“无限风光在顶峰”的畅快。

我拿这个登山的例子来类比我们学习计算机技术的过程，这两者是何其的相像。有的同学非常羡慕可以动辄写出框架的专家，其实，要成为专家，中间的一些台阶必须要自己迈过的，没有人可以一步登顶，只是有人跑得快，有人跑得慢而已。

计算机技术是一门非常重视实战的学科，网络编程更需要静下心来不断试错，因为它通过系统内核交互。只有通过试错，才能让你的理解慢慢接近真实的答案。如果只是读文字而不去实践，不可能帮助你成为一个真正的专家。

我在和同学们的互动中，发现有相当多的同学已经能动手去写，并且在这个过程中有不同的领悟，可以问出一些高质量的问题，这都是非常好的现象。坚持下去，一定会引起质变，让自己成为这个领域的专家。

学习源码

除了动手实践外，看源码也是一个快速提高自己的途径。俗话说，熟读唐诗三百首，不会做诗也会吟。源码读懂了，就可以帮助你大幅度理解原理，技巧也会得到提升。

在网络编程这块，我推荐你看 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 会让你如虎添翼。

总结需要你对原理、代码反复咀嚼。总结的方式有很多种，你可以画框图，写流程图。对技术反复考究的态度，决定了你可以在这条路上走多远。

分享这件事情使我们有一定的成就感，在这其中也不用担心出错，如果有人帮助你纠正了错误的理解，而你付出的不过是倾听和感谢，岂不是一件非常好的事情。

在互联网高度发达的今天，学会技术总结和分享，将会使我们事半功倍。

不是再见的再见

我期望这个专栏可以帮助你快速复习和掌握网络编程相关的领域知识、编程模型，为你今后的职业发展奠定良好的基础。你可以写下你的心路历程，非常期望未来和你进一步的互动，我们江湖再见。

文章结尾处有一份调查问卷，希望你可以抽出两三分钟填写一下。我想认真倾听你对这个专栏的意见或者建议，期待你的反馈！