TCP网络编程
Linux 下的 TCP 网络编程
零、序
一些关于网络编程方面的问题,你会怎样回答呢?
- 大家经常说的四层、七层,分别指的是什么?
- TCP 三次握手是什么,TIME_WAIT 是怎么发生的?
- CLOSE_WAIT 又是什么状态?
- Linux 下的 epoll 解决的是什么问题?如何使用 epoll 写出高性能的网络程序?什么是网络事件驱动模型?Reactor 模式又是什么?
学习高性能网络编程,掌握两个核心要点就可以了:第一就是理解网络协议,并在这个基础上和操作系统内核配合,感知各种网络 I/O 事件;第二就是学会使用线程处理并发。抓住这两个核心问题,也就抓住了高性能网络编程的“七寸”。
要学好网络编程,需要达到以下三个层次。
第一个层次,充分理解 TCP/IP 网络模型和协议。在这方面,仅仅做到理论上的理解是远远不够的。学会梳理 TCP/IP 模型和网络函数接口之间的联系,并通过实例理解套接字、套接字缓冲区、拥塞控制、数据包和数据流,本地套接字(UNIX 域套接字)等,建立一个全面而具体的知识体系。
第二个层次,结合对协议的理解,增强对各种异常情况的优雅处理能力。比如对 TCP 数据流的处理,半关闭的连接,TCP 连接有效性的侦测,处理各种异常情况等,这些问题决定了程序的健壮性。
第三个层次,写出可以支持大规模高并发的网络处理程序。在这个阶段,可以深入研究 C10K 问题,涉及进程、线程、多路复用、非阻塞、异步、事件驱动等现代高性能网络编程所需要的技术。
一、TCP、IP 和 Linux 历史
互联网技术里,有两件事最为重要,一个是 TCP/IP 协议,它是万物互联的事实标准;另一个是 Linux 操作系统,它是推动互联网技术走向繁荣的基石。
1.1、TCP
互联网起源于阿帕网(ARPANET)。网络控制协议(Network Control Protocol,缩写 NCP)是阿帕网中连接不同计算机的通信协议。
在构建阿帕网(ARPANET)之后,其他数据传输技术的研究又被摆上案头。NCP 诞生两年后,NCP 的开发者温特·瑟夫(Vinton Cerf)和罗伯特·卡恩(Robert E. Kahn)一起开发了一个阿帕网的下一代协议,并在 1974 年发表了以分组、序列化、流量控制、超时和容错等为核心的一种新型的网络互联协议,一举奠定了 TCP/IP 协议的基础。
TCP/IP 的成功不是偶然的,而是综合了几个因素后的结果:
- TCP/IP 是免费或者是少量收费的,这样就扩大了使用人群;
- TCP/IP 搭上了 UNIX 这辆时代快车,很快推出了基于套接字(socket)的实际编程接口;这是最重要的一点,TCP/IP 来源于实际需求,大家都在翘首盼望出一个统一标准,可是在此之前实际的问题总要解决啊,TCP/IP 解决了实际问题,并且在实际中不断完善。
1.2、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 分支也吸收了这部分能力,在上面这张大图上也可以看出来。
1.3、操作系统对 TCP/IP 的支持
下图展示了 TCP/IP 在各大操作系统的演变历史。可以看到,即使是大名鼎鼎的 Linux 以及 90 年代大发光彩的 Windows 操作系统,在 TCP/IP 网络这块,也只能算是一个后来者。
二、客户端 - 服务器网络编程模型
拿我们常用的网络购物来说,我们在手机上的每次操作,都是作为客户端向服务器发送请求,并收到响应的例子。(整个过程和 TCP/IP 四次挥手过程有点类似)
- 当一个客户端需要服务时,比如网络购物下单,它会向服务器端发送一个请求。注意,这个请求是按照双方约定的格式来发送的,以便保证服务器端是可以理解的;
- 服务器端收到这个请求后,会根据双方约定的格式解释它,并且以合适的方式进行操作,比如调用数据库操作来创建一个购物单;
- 服务器端完成处理请求之后,会给客户端发送一个响应,比如向客户端发送购物单的实际付款额,然后等待客户端的下一步操作;
- 客户端收到响应并进行处理,比如在手机终端上显示该购物单的实际付款额,并且让用户选择付款方式。
无论是客户端,还是服务器端,它们运行的单位都是进程(process),而不是机器。一个客户端,比如我们的手机终端,同一个时刻可以建立多个到不同服务器的连接,比如同时打游戏,上知乎,逛天猫;而服务器端更是可能在一台机器上部署运行了多个服务,比如同时开启了 SSH 服务和 HTTP 服务。
2.1、IP 和端口
端口号是一个 16 位的整数,最多为 65536。当一个客户端发起连接请求时,客户端的端口是由操作系统内核临时分配的,称为临时端口;然而,服务器端的端口通常是一个众所周知的端口。
一个连接可以通过客户端 - 服务器端的 IP 和端口唯一确定,这叫做套接字对,按照下面的四元组表示:
1 | (clientaddr:clientport, serveraddr:serverport) |
2.2、保留网段
国际标准组织在 IPv4 地址空间里面,专门划出了一些网段,这些网段不会用做公网上的 IP,而是仅仅保留作内部使用,我们把这些地址称作保留网段。
2.3、子网掩码
第一是网络(network)的概念,直观点说,它表示的是这组 IP 共同的部分,比如在 192.168.1.1~192.168.1.255 这个区间里,它们共同的部分是 192.168.1.0。
第二是主机(host)的概念,它表示的是这组 IP 不同的部分,上面的例子中 1~255 就是不同的那些部分,表示有 255 个可用的不同 IP。
网络地址位数由子网掩码(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。
2.4、全球域名系统
全球域名按照从大到小的结构,形成了一棵树状结构。实际访问一个域名时,是从最底层开始写起,例如 www.google.com,www.tinghua.edu.cn 等。
2.5、数据报和字节流
TCP,又被叫做字节流套接字(Stream Socket),注意我们这里先引入套接字 socket,套接字 socket 在后面几讲中将被反复提起,因为它实际上是网络编程的核心概念。当然,UDP 也有一个类似的叫法, 数据报套接字(Datagram Socket),一般分别以“SOCK_STREAM”与“SOCK_DGRAM”分别来表示 TCP 和 UDP 套接字。
TCP(Transmission Control Protocol)通过诸如连接管理,拥塞控制,数据流与窗口管理,超时和重传等一系列精巧而详细的设计,提供了高质量的端到端的通信方式。
UDP 在很多场景也得到了极大的应用,比如多人联网游戏、视频会议,甚至聊天室。如果你听说过 NTP,你一定很惊讶 NTP 也是用 UDP 实现的。
使用 UDP 的原因,第一是速度,第二还是速度。
想象一下,一个有上万人的联网游戏,如果要给每个玩家同步游戏中其他玩家的位置信息,而且丢失一两个也不会造成多大的问题,那么 UDP 是一个比较经济合算的选择。
还有一种叫做广播或多播的技术,就是向网络中的多个节点同时发送信息,这个时候,选择 UDP 更是非常合适的。
UDP 也可以做到更高的可靠性,只不过这种可靠性,需要应用程序进行设计处理,比如对报文进行编号,设计 Request-Ack 机制,再加上重传等,在一定程度上可以达到更为高可靠的 UDP 程序。当然,这种可靠性和 TCP 相比还是有一定的距离,不过也可以弥补实战中 UDP 的一些不足。
2.6 思考题
- 172.16.0.0
172.31.255.255,因为 b 类网络的 host 只占最后两个字节,172.16172.31就代表了16个连续的 b 类网络可用。 - 192.168.0.0~192.168.255.255,因为 c 类网络的 host 只占最后一个字节,所以从192.168.0到192.168.255,就有256个连续的 c 类网络可用。
- 服务器可以监听的端口有从0到65535,理论上这台服务器的这个端口只要没被占用,你都可以给服务器绑定。
- 如果是一些默认的服务,服务器绑的也是默认的端口,那么客户端是可以知道的。比如80是给 http 服务,443是给 https 服务,21是给 ftp 服务等。否则的话,就需要服务器开发者告诉客户端应该连接哪个端口。
三、套接字和地址
socket 这个英文单词的原意是“插口”“插槽”, 在网络编程中,它的寓意是可以通过插口接入的方式,快速完成网络连接和数据收发。你可以把它想象成现实世界的电源插口,或者是早期上网需要的网络插槽,所以 socket 也可以看做是对物理世界的直接映射。
3.1、socket 到底是什么?
客户端发起连接请求之前,服务器端必须初始化好。右侧的图显示的是服务器端初始化的过程,首先初始化 socket,之后服务器端需要执行 bind 函数,将自己的服务能力绑定在一个众所周知的地址和端口上,紧接着,服务器端执行 listen 操作,将原先的 socket 转化为服务端的 socket,服务端最后阻塞在 accept 上等待客户端请求的到来。
客户端需要先初始化 socket,再执行 connect 向服务器端的地址和端口发起连接请求,这里的地址和端口必须是客户端预先知晓的。这个过程,就是著名的 TCP 三次握手(Three-way Handshake)。
一旦三次握手完成,客户端和服务器端建立连接,就进入了数据传输过程。
客户端进程向操作系统内核发起 write 字节流写操作,内核协议栈将字节流通过网络设备传输到服务器端,服务器端从内核得到信息,将字节流从内核读入到进程中,并开始业务逻辑的处理,完成之后,服务器端再将得到的结果以同样的方式写给客户端。可以看到,一旦连接建立,数据的传输就不再是单向的,而是双向的,这也是 TCP 的一个显著特性。
当客户端完成和服务器端的交互后,比如执行一次 Telnet 操作,或者一次 HTTP 请求,需要和服务器端断开连接时,就会执行 close 函数,操作系统内核此时会通过原先的连接链路向服务器端发送一个 FIN 包,服务器收到之后执行被动关闭,这时候整个链路处于半关闭状态,此后,服务器端也会执行 close 函数,整个链路才会真正关闭。半关闭的状态下,发起 close 请求的一方在没有收到对方 FIN 包之前都认为连接是正常的;而在全关闭的状态下,双方都感知连接已经关闭。
你可以把整个 TCP 的网络交互和数据传输想象成打电话,顺着这个思路想象,socket 就好像是我们手里的电话机,connect 就好比拿着电话机拨号,而服务器端的 bind 就好比是去电信公司开户,将电话号码和我们家里的电话机绑定,这样别人就可以用这个号码找到你,listen 就好似人们在家里听到了响铃,accept 就好比是被叫的一方拿起电话开始应答。至此,三次握手就完成了,连接建立完毕。
接下来,拨打电话的人开始说话:“你好。”这时就进入了 write,接收电话的人听到的过程可以想象成 read(听到并读出数据),并且开始应答,双方就进入了 read/write 的数据传输过程。
3.2、套接字地址格式
1 | /* POSIX.1g 规范规定了地址族为2字节的值. */ |
在这个结构体里,第一个字段是地址族,它表示使用什么样的方式对地址进行解释和保存,好比电话簿里的手机格式,或者是固话格式,这两种格式的长度和含义都是不同的。地址族在 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。我们在 头文件中可以清晰地看到,这两个值本身就是一一对应的。
1 | /* 各种地址族的宏定义 */ |
3.3、IPv4 套接字格式地址
1 | /* IPV4套接字地址,32bit值. */ |
接下来是端口号,我们可以看到端口号最多是 16-bit,也就是说最大支持 2 的 16 次方,这个数字是 65536,所以我们应该知道支持寻址的端口号最多就是 65535。所谓保留端口就是大家约定俗成的,已经被对应服务广为使用的端口,比如 ftp 的 21 端口,ssh 的 22 端口,http 的 80 端口等。一般而言,大于 5000 的端口可以作为我们自己应用程序的端口使用。
1 | /* Standard well-known ports. */ |
3.4、IPv6 套接字地址格式
1 | struct sockaddr_in6 |
整个结构体长度是 28 个字节,其中流控信息和域 ID 先不用管,这两个字段,一个在 glibc 的官网上根本没出现,另一个是当前未使用的字段。这里的地址族显然应该是 AF_INET6,端口同 IPv4 地址一样,关键的地址从 32 位升级到 128 位,这个数字就大到恐怖了,完全解决了寻址数字不够的问题。
请注意,以上无论 IPv4 还是 IPv6 的地址格式都是因特网套接字的格式,还有一种本地套接字格式,用来作为本地进程间的通信,也就是前面提到的 AF_LOCAL。
1 | struct sockaddr_un { |
3.5 几种套接字地址格式比较
IPv4 和 IPv6 套接字地址结构的长度是固定的,而本地地址结构的长度是可变的。
3.6、思考题
- 像 sock_addr 的结构体里描述的那样,几种套接字都要有地址族和地址两个字段。这容易理解,你要与外部通信,肯定要至少告诉计算机对方的地址和使用的是哪一种地址。与远程计算机的通信还需要一个端口号。而本地 socket 的不同之处在于不需要端口号,那么就有了问题2;
- 本地 socket 本质上是在访问本地的文件系统,所以自然不需要端口。远程 socket 是直接将一段字节流发送到远程计算机的一个进程,而远程计算机可能同时有多个进程在监听,所以用端口号标定要发给哪一个进程。
五、非阻塞 IO
非阻塞 I/O 配合 I/O 多路复用,是高性能网络编程中的常见技术。
5.1、阻塞 VS 非阻塞
当应用程序调用阻塞 I/O 完成某个操作时,应用程序会被挂起,等待内核完成操作,感觉上应用程序像是被“阻塞”了一样。实际上,内核所做的事情是将 CPU 时间切换给其他有需要的进程,网络应用程序在这种情况下就会得不到 CPU 时间做该做的事情。
当应用程序调用非阻塞 I/O 完成某个操作时,内核立即返回,不会把 CPU 时间切换给其他进程,应用程序在返回后,可以得到足够的 CPU 时间继续完成其他事情。
如果拿去书店买书举例子,阻塞 I/O 对应什么场景呢?你去了书店,告诉老板(内核)你想要某本书,然后你就一直在那里等着,直到书店老板翻箱倒柜找到你想要的书,有可能还要帮你联系全城其它分店。注意,这个过程中你一直滞留在书店等待老板的回复,好像在书店老板这里”阻塞”住了。
那么非阻塞 I/O 呢?你去了书店,问老板有没你心仪的那本书,老板查了下电脑,告诉你没有,你就悻悻离开了。一周以后,你又来这个书店,再问这个老板,老板一查,有了,于是你买了这本书。注意,这个过程中,你没有被阻塞,而是在不断轮询。
但轮询的效率太低了,于是你向老板提议:“老板,到货给我打电话吧,我再来付钱取书。”这就是前面讲到的 I/O 多路复用。
再进一步,你连去书店取书也想省了,得了,让老板代劳吧,你留下地址,付了书费,让老板到货时寄给你,你直接在家里拿到就可以看了。这就是异步 I/O。
这几个 I/O 模型,再加上进程、线程模型,构成了整个网络编程的知识核心。
按照使用场景,非阻塞 I/O 可以被用到读操作、写操作、接收连接操作和发起连接操作上。接下来,我们对它们一一解读。
5.2、非阻塞 I/O
读操作
如果套接字对应的接收缓冲区没有数据可读,在非阻塞情况下 read 调用会立即返回,一般返回 EWOULDBLOCK 或 EAGAIN 出错信息。在这种情况下,出错信息需要小心处理,比如后面再次调用 read 操作,而不是直接作为错误直接返回。这就好像去书店买书没买到离开一样,需要不断进行又一次轮询处理。
写操作
在阻塞 I/O 情况下,write 函数返回的字节数,和输入的参数总是一样的。如果返回值总是和输入的数据大小一样,write 等写入函数还需要定义返回值吗?
在非阻塞 I/O 的情况下,如果套接字的发送缓冲区已达到了极限,不能容纳更多的字节,那么操作系统内核会尽最大可能从应用程序拷贝数据到发送缓冲区中,并立即从 write 等函数调用中返回。可想而知,在拷贝动作发生的瞬间,有可能一个字符也没拷贝,有可能所有请求字符都被拷贝完成,那么这个时候就需要返回一个数值,告诉应用程序到底有多少数据被成功拷贝到了发送缓冲区中,应用程序需要再次调用 write 函数,以输出未完成拷贝的字节。
write 等函数是可以同时作用到阻塞 I/O 和非阻塞 I/O 上的,为了复用一个函数,处理非阻塞和阻塞 I/O 多种情况,设计出了写入返回值,并用这个返回值表示实际写入的数据大小。
非阻塞 I/O 和阻塞 I/O 处理的方式是不一样的。
非阻塞 I/O 需要这样:拷贝→返回→再拷贝→再返回。
而阻塞 I/O 需要这样:拷贝→直到所有数据拷贝至发送缓冲区完成→返回。
1 | /* 向文件描述符fd写入n字节数 */ |
read 和 write 在阻塞模式和非阻塞模式下的不同行为特性:
read 总是在接收缓冲区有数据时就立即返回,不是等到应用程序给定的数据充满才返回。当接收缓冲区为空时,阻塞模式会等待,非阻塞模式立即返回 -1,并有 EWOULDBLOCK 或 EAGAIN 错误。
和 read 不同,阻塞模式下,write 只有在发送缓冲区足以容纳应用程序的输出字节时才返回;而非阻塞模式下,则是能写入多少就写入多少,并返回实际写入的字节数。
阻塞模式下的 write 有个特例,就是对方主动关闭了套接字,这个时候 write 调用会立即返回,并通过返回值告诉应用程序实际写入的字节数,如果再次对这样的套接字进行 write 操作,就会返回失败。失败是通过返回值 -1 来通知到应用程序的。
参考文章:
网络编程实战
- 本文标题:TCP网络编程
- 本文作者:beyondhxl
- 本文链接:https://www.beyondhxl.com/post/6af8bd79.html
- 版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明出处!