网络编程实战

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

而具体到互联网技术里,有两件事最为重要,一个是 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 的成功也不是偶然的,而是综合了几个因素后的结果:

  1. TCP/IP 是免费或者是少量收费的,这样就扩大了使用人群;
  2. TCP/IP 搭上了 UNIX 这辆时代快车,很快推出了基于套接字(socket)的实际编程接口;
  3. 这是最重要的一点,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 呢?

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

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

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

这个过程具体阐释如下:

  1. 当一个客户端需要服务时,比如网络购物下单,它会向服务器端发送一个请求。注意,这个请求是按照双方约定的格式来发送的,以便保证服务器端是可以理解的;
  2. 服务器端收到这个请求后,会根据双方约定的格式解释它,并且以合适的方式进行操作,比如调用数据库操作来创建一个购物单;
  3. 服务器端完成处理请求之后,会给客户端发送一个响应,比如向客户端发送购物单的实际付款额,然后等待客户端的下一步操作;
  4. 客户端收到响应并进行处理,比如在手机终端上显示该购物单的实际付款额,并且让用户选择付款方式。

在网络编程中,具体到客户端 - 服务器模型时,我们经常会考虑是使用 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.comwww.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 地址、端口、子网掩码和域名等基础概念,以下知识点你需要重点关注一下:

  1. 网络编程需要牢牢树立起“客户端”和“服务器”模型,两者编程的方法和框架是明显不同的。
  2. TCP 连接是客户端 - 服务器的 IP 和端口四元组唯一确定的,IP 是一台机器在网络世界的唯一标识。
  3. 有两种截然不同的传输层协议,面向连接的“数据流”协议 TCP,以及无连接的“数据报”协议 UDP。

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

在网络编程中,我们经常会提到 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 以及本地套接字格式,这一讲我们来讲一讲怎么使用这些套接字格式完成连接的建立,当然,经典的 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 的三次握手过程,而且仅在连接建立成功或出错时才返回。其中出错返回可能有以下几种情况:

  1. 三次握手无法建立,客户端发出的 SYN 包没有任何响应,于是返回 TIMEOUT 错误。这种情况比较常见的原因是对应的服务端 IP 写错。
  2. 客户端收到了 RST(复位)回答,这时候客户端会立即返回 CONNECTION REFUSED 错误。这种情况比较常见于客户端发送连接请求时的请求端口写错,因为 RST 是 TCP 在发生错误时发送的一种 TCP 分节。产生 RST 的三个条件是:目的地为某端口的 SYN 到达,然而该端口上没有正在监听的服务器(如前所述);TCP 想取消一个已有连接;TCP 接收到一个根本不存在的连接上的分节。
  3. 客户发出的 SYN 包在网络上引起了"destination unreachable",即目的不可达的错误。这种情况比较常见的原因是客户端和服务器端路由不通。

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

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


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

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

TCP 三次握手的解读

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

下面是具体的过程:

  1. 客户端的协议栈向服务器端发送了 SYN 包,并告诉服务器端当前发送序列号 j,客户端进入 SYNC_SENT 状态;
  2. 服务器端的协议栈收到这个包之后,和客户端进行 ACK 应答,应答的值为 j+1,表示对 SYN 包 j 的确认,同时服务器也发送一个 SYN 包,告诉客户端当前我的发送序列号为 k,服务器端进入 SYNC_RCVD 状态;
  3. 客户端协议栈收到 ACK 之后,使得应用程序从 connect 调用返回,表示客户端到服务器端的单向连接建立成功,客户端的状态为 ESTABLISHED,同时客户端协议栈也会对服务器端的 SYN 包进行应答,应答数据为 k+1;
  4. 应答包到达服务器端后,服务器端协议栈使得 accept 阻塞调用返回,这个时候服务器端到客户端的单向连接也建立成功,服务器端也进入 ESTABLISHED 状态。

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

  • A 先对 B 说:“喂,你在么?我在的,我的口令是 j。”
  • B 收到之后大声回答:“我收到你的口令 j 并准备好了,你准备好了吗?我的口令是 k。”
  • A 收到之后也大声回答:“我收到你的口令 k 并准备好了,我们开始吧。”

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

总结

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

  • 服务器端通过创建 socket,bind,listen 完成初始化,通过 accept 完成连接的建立。
  • 客户端通过场景 socket,connect 发起连接建立请求。

如何使用创建的套接字收发数据。

连接建立的根本目的是为了数据的收发。拿我们常用的网购场景举例子,我们在浏览商品或者购买货品的时候,并不会察觉到网络连接的存在,但是我们可以真切感觉到数据在客户端和服务器端有效的传送, 比如浏览商品时商品信息的不断刷新,购买货品时显示购买成功的消息等。

首先我们先来看一下发送数据。

发送数据

发送数据时常用的有三个函数,分别是 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 等异常条件。

如果说 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 函数直接收发数据报报文。

 

实际上,本地套接字是 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 的字节流、数据报套接字实现。

思考题

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

  1. 在本地套接字字节流类型的客户端 - 服务器例子中,我们让服务器端以 root 账号启动,监听在 /var/lib/unixstream.sock 这个文件上。如果我们让客户端以普通用户权限启动,客户端可以连接上 /var/lib/unixstream.sock 吗?为什么呢?
  2. 我们看到客户端被杀死后,服务器端也正常退出了。看下退出后打印的日志,你不妨判断一下引起服务器端正常退出的逻辑是什么?
  3. 你有没有想过这样一个奇怪的场景:如果自己不小心写错了代码,本地套接字服务器端是 SOCK_DGRAM,客户端使用的是 SOCK_STREAM,路径和其他都是正确的,你觉得会发生什么呢?

 

posted @ 2026-03-22 14:41  CharyGao  阅读(44)  评论(0)    收藏  举报