Unix网络编程学习笔记

学习时间:2023年3月22日

学习来源:Unix网络编程第一卷(Uinx Network ProgrammingUNP

0 源码编译

官网源码:http://www.unpbook.com/src.html

编译步骤:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
QUICK AND DIRTY
===============

Execute the following from the src/ directory:

./configure # try to figure out all implementation differences

cd lib # build the basic library that all programs need
make # use "gmake" everywhere on BSD/OS systems

cd ../libfree # continue building the basic library
make

# note the following two lines are not needed on Linux systems
cd ../libroute # only if your system supports 4.4BSD style routing sockets
make # only if your system supports 4.4BSD style routing sockets

cd ../intro # build and test a basic client program
make daytimetcpcli
./daytimetcpcli 127.0.0.1

1 简介

1.1 概述

客户与服务器之间的通信涉及网络通信协议。本书的焦点是TCP/TP协议族,也称为网际协议族。例如Web客户与服务器之间的通信就使用TCP协议。而TCP又使用IP协议,IP则使用某种形式的数据链路层通信。举例来说,如果客户与服务器处于同一个以太网,我们就有图1.3的通信层次。

image-20230324164008621

值得注意的是,客户与服务器是典型的用户进程,而TCP和IP协议则通常是系统内核协议栈的一部分。我们在图1.3右边标出了四层协议。

image-20230324164216483

1.2 一个简单的时间/日期客户程序

功能:这个客户建立与服务器的TCP连接并读取服务器送回的当前时间和日期(直观可读格式)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include	"unp.h"

int main(int argc, char **argv) {
// 套接字
int sockfd, n;
// 缓冲区 MAXLINE=4096
char recvline[MAXLINE + 1];
// 服务器(对端)地址,IPv4
struct sockaddr_in servaddr;

if (argc != 2)
err_quit("usage: a.out <IPaddress>");
// 创建套接字(流式套接字)
if ( (sockfd = socket(AF_INET, SOCK_STREAM, 0)) < 0)
err_sys("socket error");
// 配置服务器地址信息
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET; // 地址族
servaddr.sin_port = htons(13); /* daytime server */
// 将点分十进制的文本字符串argv[1]格式转换为网络字节序的地址
if (inet_pton(AF_INET, argv[1], &servaddr.sin_addr) <= 0)
err_quit("inet_pton error for %s", argv[1]);

// 连接 #define SA struct sockaddr 通用的套接字地址结构
if (connect(sockfd, (SA *) &servaddr, sizeof(servaddr)) < 0)
err_sys("connect error");
// 读取服务器发来的数据
while ( (n = read(sockfd, recvline, MAXLINE)) > 0) {
recvline[n] = 0; /* null terminate */
// 将缓冲区内容输出到终端
if (fputs(recvline, stdout) == EOF)
err_sys("fputs error");
}
if (n < 0)
err_sys("read error");

exit(0);
}

程序解释:作者对常用的套接字函数进行了封装。

头文件:第1行 包含一个头文件unp.h,这个头文件含有许多大部分网络程序都需要的系统头文件,并定义了所用到的各种常值(如MAXLINE

bzero函数:能够将内存块(字符串)的前n个字节清零,在string.h头文件中,原型为:

1
void bzero(void *s, int n);

功能上和memset相同,但参数少一个。

1.3 协议无关性

上述程序是与IPv4相关的。分配并初始化 sockaddr_in 结构,置 sin_family 成员为AF_INET,指定 socket 函数的第一个参数为 AF_INET

为使程序可在IPv6上运行,我们必须修改程序代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include	"unp.h"

int main(int argc, char **argv) {
int sockfd, n;
// IPv6地址结构体
struct sockaddr_in6 servaddr;
char recvline[MAXLINE + 1];

if (argc != 2)
err_quit("usage: a.out <IPaddress>");

// 设置套接字地址协议为AF_INET6
if ( (sockfd = socket(AF_INET6, SOCK_STREAM, 0)) < 0)
err_sys("socket error");

bzero(&servaddr, sizeof(servaddr));
servaddr.sin6_family = AF_INET6; // 服务器地址协议AF_INET6
servaddr.sin6_port = htons(13); /* daytime server */
if (inet_pton(AF_INET6, argv[1], &servaddr.sin6_addr) <= 0)
err_quit("inet_pton error for %s", argv[1]);

if (connect(sockfd, (SA *) &servaddr, sizeof(servaddr)) < 0)
err_sys("connect error");

while ( (n = read(sockfd, recvline, MAXLINE)) > 0) {
recvline[n] = 0; /* null terminate */
if (fputs(recvline, stdout) == EOF)
err_sys("fputs error");
}
if (n < 0)
err_sys("read error");

exit(0);
}

1.4 错误处理

因为多数情况下程序终止于一个错误,我们可以定义包裹函数来简化我们的程序。包裹函数调用实际函数,检查返回值,发生错误时终止进程。确定包裹函数名的约定是大写实际函数名的第一个字符,如:

1
sockfd = Socket(AF_INET, SOCK_STREAM, 0);

包裹函数:

1
2
3
4
5
6
7
8
// lib/wrapsock.c
/* include Socket */
int Socket(int family, int type, int protocol) {
int n;
if ( (n = socket(family, type, protocol)) < 0)
err_sys("socket error");
return(n);
}

约定:每当遇到一个以大写字母打头的函数名时,它就是我们定义的包裹函数。它调用的实际函数的名字与包裹函数名相同,但以对应的小写字母打头。

Unix errno 值

每当在一个Unix函数(如 socket 函数)中发生错误时,全局变量 errno 将被置成一个指示错误类型的正数,函数本身则通常返回-1err_sys 检查 errno变量并输出其相应的出错消息(例如,当 errno 值等于 ETIMEDOUT时,将输出”Connection timed out(连接超时)”)。

errno 的值只在函数发生错误时设置。如果函数不返回错误,errno的值就无定义。在Unix中,所有的错误值都是常值,具有以E打头的全大写字母名字,在头文件(sys/errno.n)中定义。值0不表示任何错误。

把errno 值存于全局变量不适合共享所有全局变量的多线程。我们将在23章中讲述解决这一问题的方法。

1.5 一个简单的时间/日期服务器程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include	"unp.h"
#include <time.h>

int main(int argc, char **argv) {
// 监听套接字,副套接字
int listenfd, connfd;
// 本机地址
struct sockaddr_in servaddr;
char buff[MAXLINE];
time_t ticks;
// 创建监听套接字
listenfd = Socket(AF_INET, SOCK_STREAM, 0);
// 配置本机地址,需要发送到网络上,因此都需要转换成网络字节序
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
/* Address to accept any incoming messages. */
// #define INADDR_ANY ((in_addr_t) 0x00000000)
// htonl:主机字节序转换成long网络字节序
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
// htons:主机字节序转换成short网络字节序
servaddr.sin_port = htons(13); /* daytime server */
// 绑定主套接字和本机地址
Bind(listenfd, (SA *) &servaddr, sizeof(servaddr));
// 监听
Listen(listenfd, LISTENQ);

for ( ; ; ) {
connfd = Accept(listenfd, (SA *) NULL, NULL);

ticks = time(NULL);
snprintf(buff, sizeof(buff), "%.24s\r\n", ctime(&ticks));
Write(connfd, buff, strlen(buff));

Close(connfd);
}
}

程序解释:

把套接字变换成监听套接口

通过调用listen函数将此套接口变换成一个监听套接口,它使系统内核接受来自客户的连接。socket、bind和listen是任何TCP服务器用于准备所谓的监听描述字(listening descriptor,本例为listenfd)通常的三个步骤。常值LISTENQ在头文件unp.h中定义,它指定系统内核允许在这个监听描述字上排队的最大客户连接数。

接收客户连接,发送应答

一般情况下,服务器进程在调用accept函数后处于睡眠状态(阻塞),它等待客户的连接和内核对它的接受。TCP连接使用三路握手(three-way handshake)来建立,当握手完毕时,accept 函数返回,其返回值是一个称为已连接描述字(connected descriptor)的新描述字(connfd)。此描述字用于与新客户的通信。accept为每个连接到服务器的客户返回一个新的已连接描述字。

终止连接

服务器通过调用close关闭与客户的连接。它引发通常的TCP连接终止序列:每个方向上发送一个FIN,每个FIN又由对方确认。

效果

1
2
3
4
5
6
[root@HongyiZeng intro]# ./daytimetcpsrv 

# 另一个终端

[root@HongyiZeng intro]# ./daytimetcpcli 127.0.0.1
Sun Mar 26 16:09:55 2023

1.6 OSI模型

描述网络中各协议层的一般方法是国际标准化组织(ISO)的计算机通信开放系统互连(open systems interconnection,OSI)模型。这是一个七层模型,如图1.14所示,图中同时给出了与网际协议族的近似映射。

image-20230326161126015

我们认为OSI模型的底下两层是随系统提供的设备驱动程序和网络硬件。除需知道数据链路的某些特性如1500字节的以太网MTU外,我们不必关心这两层。

网络层由IPv4和IPv6协议处理。传输层可以选择TCP或UDP。图1.14中的网际协议族,在TCP与UDP之间留有一个间隙,指出应用程序可以绕过传输层而直接使用 IPv4 或 IPv6。这称为原始套接口(raw socket)。

OSI模型的上面三层合并成一层,称为应用层。这就是Web客户(浏览器)、Telnet客户、Web 服务器、FTP服务器或其他应用进程所在的层。对于网际协议,OSI模型的上三层协议没什么区别。

1.7 64位体系结构

已有的32位 Unix系统上的一般编程模型称为ILP32模型,表示整数(I)、长整数(L)和指针(P)都占用32位;64位U-nix系统上最为流行的编程模型称为LP64模型,它表示长整数(L)和指针(P)都占用64位。图1.17是这两种模型的比较。

image-20230326161517903

从编程角度看,LP64模型意味着我们不能假设一个指针能存放到一个整数中。我们还必须考虑LP64模型对已有的API的影响。

1.8 TCP/IP协议栈实现机制

tcp/ip协议栈属于操作系统内核层,通过提供系统调用供用户空间访问,从数据报到达最底层的网卡到最终传递给上层软件有一个过程,当一个数据报到达时,网络驱动程序把数据报放到一个队列中,同时发送一个消息给ip进程,这里ip进程是一个独立的程序,专门处理ip数据报,tcp/ip协议栈中,根据协议的功能及复杂程度,一般通过进程方式实现,而协议间的数据传递则借助于操作系统提供的进程间通讯机制,当ip进程接受了一个传入的数据报,他必须决定将其发往何处作进一步处理,如果数据报中的内容是一个报文段,则必须将其交付给TCP模块,如果他携带的是用户数据报(UDP),则必须将其交付给udp模块,以此类推。

由于TCP比较复杂,因而在许多设计方案中,有一个独立的进程来处理传入的TCP报文段,由于IP和TCP有各自独立的进程执行,因而IP和TCP必须借助进程间的通信机制来通信。

一旦tcp模块收到ip进程传送过来的报文段,就利用tcp协议端口号来寻找该报文段所属的连接,如果报文段中含有数据,TCP将把数据添加到与该连接相关的一个缓冲区中,并给发送方返回一个确认,如果输入的报文段中含有对放送出去的数据的确认,tcp输入进程还必须与tcp定时器管理进程通信,取消超时重发事件。

而处理udp数据报的进程结构与处理tcp进程采用的结构不同,由于udp比tcp要简单,udp模块不作为独立进程存在,事实上,它是由一些常规过程组成。ip进程通过调用来处理传入的udp数据报,这些过程检查udp目的站的协议端口号,根据端口号为udp数据报选择一个操作系统队列,ip进程把udp数据报放在响应的端口中,是应用程序可从这些端口中提取数据报。

img

img

2 传输层

2.1 总图

image-20230330104139428

在这个图中,我们展示了IPv4和IPv6。从右向左观察这个图,最右边的4个应用程序使用IPv6,这涉及到第3章中的AF_INET6常值和sockaddr_in6结构。另外的5个应用程序使用IPv4。

最左边的应用程序(tcpdump)直接使用BPF(BSD分组过滤器)或DLPI(数据链路提供者接口)同数据链路层进行通信。右边9个应用程序的下面用虚线标记出API,它通常是套接口或XTI。使用BPF或DLPI的接口不用套接口或XTI。

2.2 用户数据报协议UDP

UDP是一个简单的传输层协议。应用进程写一数据报到UDP套接口,由它封装(encapsulating)成IPv4或IPv6数据报,然后发送到目的地。但是,UDP并不能确保UDP数据报最终可到达目的地。

我们使用UDP进行网络编程所碰到的问题是缺乏可靠性。如果我们要确保一个数据报到达目的地,我们必须在应用程序里建立一大堆的特性:来自另一端的确认、超时、重传等等。

每个UDP数据报都有一定长度,我们可以认为一个数据报就是一个记录。如果数据报最终正确地到达目的地(即分组到达目的地且校验和正确),那么数据报的长度将传递给接收方的应用进程。我们已经提到TCP是一字节流协议,无记录边界,这与UDP不同。

我们也称UDP提供无连接的(connectionless)服务,因为UDP客户与服务器不必存在长期的关系。例如,一个UDP客户可以创建一个套接口并发送一个数据报给一个服务器,然后立即用同一个套接口发送另一个数据报给另一个服务器。同样,一个UDP服务器可以用同一个UDP套接口从5个不同的客户一连串接收5个数据报。

img

总结:UDP协议的特点

  • 无连接:当发送方的socket创建好之后,就可以立即尝试读写数据。
  • 不可靠传输:由于数据在网络上传输存在丢包及传输错误甚至被恶意篡改的情况,UDP无法规避这些情况。
  • 面向数据报:以一个一个的数据报为基本单位(每个数据报多大,不同的协议里面是有不同的约定的)
    • 发送的时候,一次至少发一个数据报
    • 接收的时候,一次至少接收一个数据报
  • 全双工

2.3 传输控制协议TCP

TCP报文首部格式:

img

2.3.1 特点

  • 有连接:TCP提供客户与服务器的连接(connection)。一个TCP客户建立与一个给定服务器的连接,跨越连接与那个服务器交换数据,然后终止连接。
  • 可靠性。当TCP向另一端发送数据时,它要求对方返回一个确认。如果确认没有收到,TCP自动重传数据并等待更长时间。在数次重传失败后,TCP才放弃。TCP含有用于动态估算客户到服务器往返所花时间RTT(round-trip time)的算法,因此它知道等待一确认需要多少时间。
  • 有序:TCP通过给所发送数据的每一个字节关联一个序列号进行排序。例如,假设一个应用进程写2048字节到一个TCP套接口,导致TCP发送2个分节,第1个分节所含数据的序列号为1~1024,第2个分节所含数据的序列号为1025~2048(分节是TCP传递给IP的数据单元)。如果这些分节非顺序到达,接收方的TCP将根据它们的序列号重新排序,再把结果数据传递给应用进程。如果TCP接收到重复的数据(譬如说对方认为一个分节已丢失并因而重传,而它并没有真正丢失,只是刚才网络通信过于拥挤),它也可以判定数据是重复的(根据序列号),从而把它丢弃掉。
  • 流量控制:TCP总是告诉对方它能够接收多少字节的数据,这称为通告窗口(advertised window)。任何时刻,这个窗口指出接收缓冲区中的可用空间,从而确保发送方发送的数据不会溢出接收缓冲区。窗口时刻动态地变化:当接收发送方的数据时,窗口大小减小,而当接收方应用进程从缓冲区中读取数据时,窗口大小增大。窗口的大小减小到0是有可能的:TCP的接收缓冲区满,它必须等待应用进程从这个缓冲区读取数据后才能再接收从发送方来的数据
  • 全双工

2.3.2 连接的建立与终止

① 三次握手

下述步骤建立一个TCP连接:

  1. 服务器必须准备好接受外来的连接。这通过调用socketbindlisten函数来完成,称为被动打开(passive open)。
  2. 客户通过调用connect进行主动打开(active open)。这引起客户TCP发送一个SYN分节(表示同步),它告诉服务器客户将在(待建立的)连接中发送的数据的初始序列号。一般情况下SYN分节不携带数据,它只含有一个IP头部、一个TCP头部及可能有的TCP选项。
  3. 服务器必须确认客户的SYN,同时自己也得发送一个SYN分节,它含有服务器将在同一连接中发送的数据的初始序列号。服务器以单个分节向客户发送SYN和对客户SYN的ACK。
  4. 客户必须确认服务器的SYN。

连接建立过程至少需要交换三个分组,因此称之为TCP的三路握手(three-way hand-shake)。我们在图 2.2中展示这三个分节。

image-20230411111458823

图2.2给出的客户的初始序列号为J,而服务器的初始序列号为K。在ACK里的确认号为发送这个ACK的一方所期待的对方的下一个序列号。因为SYN只占一个字节的序列号空间,所以每一个SYN的ACK中的确认号都是相应的初始序列号加1。类似地,每一个FIN的ACK中的确认号为FIN的序列号加1。

② TCP选项
  • MSS选项。发送SYN的TCP一端使用本选项通告对端它的最大分节大小(maximum segment size)即MSS,也就是它在本连接的每个TCP分节中愿意接受的最大数据量。发送端TCP使用接收端的MSS值作为所发送分节的最大大小
  • 窗口规模选项:主要指的是滑动窗口中窗口的规模。TCP连接任何一端能够通告对端的最大窗口大小是65535,因为在TCP首部中相应的字段占16位。
③ 终止连接

TCP用三个分节建立一个连接,终止一个连接则需四个分节(四次挥手)。

  1. 某个应用进程首先调用close,我们称这一端执行主动关闭(active close)。这一端的TCP于是发送一个FIN分节,表示数据发送完毕。
  2. 接收到FIN的另一端执行被动关闭(passive close)。这个FIN由TCP确认。它的接收也作为文件结束符传递给接收方应用进程(放在已排队等候该应用进程接收的任何其他数据之后),因为FIN的接收意味着应用进程在相应连接上再也接收不到额外数据。
  3. 一段时间后,接收到文件结束符的应用进程将调用close关闭它的套接口。这导致它的TCP也发送一个FIN。
  4. 接收到这个FIN的原发送方TCP(即执行主动关闭的那一端)对它进行确认。因为每个方向都需要有一个FIN和一个ACK,所以一般需要四个分节。我们使用限定词”一般”是因为:有时步骤1的FIN随数据一起发送;另外,执行被动关闭那一端的TCP在步骤2和3发出的ACK与FIN也可以合并成一个分节。图2.3说明了这些分组的交换过程。

image-20230411112149258

FIN占据1个字节的序列号空间,这与SYN相同。所以每个FIN的ACK确认号是这个FIN的序列号加1。

在步骤2与步骤3之间可以有从执行被动关闭端到执行主动关闭端的数据流,这称为半关闭(half-close)。套接口关闭时,每一端TCP都要发送一个FIN。这种情况在应用进程调用close时会发生,然而在进程终止时,所有打开的描述字将自愿(调用 exit 或从 main 函数返回)或不自愿(进程收到一个终止本进程的信号)地关闭,此时仍然打开的TCP连接上也会发出一个FIN。

图2.3指出客户执行主动关闭,然而不管是客户还是服务器都可以执行主动关闭。通常情况是客户执行主动关闭,但某些协议如HTTP(超文本传送协议)则是服务器执行主动关闭。

④ 状态转换图

image-20230330104212752

示例

本例中的客户通告一个值为536的MSS(表明该客户只实现了最小重组缓冲区大小),服务器通告一个值为1460的MSS(以太网上IPv4的典型值)。不同方向上MSS值不相同不成问题。

image-20230411112647035

注意:服务器是捎带确认。

⑤ TIME_WAIT状态

毫无疑问,TCP中有关网络编程最不容易理解的是它的TIME_WAIT状态。在图2-4中我们看到执行主动关闭的那端经历了这个状态。该端点停留在这个状态的持续时间是最长分节生命期(maximum segment lifetime, MSL)的两倍,有时候称之为2MSL

TIME_WAIT状态有两个存在的理由:

  1. 可靠地实现TCP全双工连接的终止;
  2. 允许老的重复分节在网络中消逝。

2.3.3 流量控制

TCP的流量控制机制为了解决端到端的数据传输速率问题。

所谓流量控制就是根据接收方的实际接收能力,来控制发送方的数据发送速率。从而让发送方的发送速率不要太快,要让接收方来得及接收。

TCP协议使用滑动窗口机制来实现对发送方的流量控制。

2.3.4 拥塞控制

在某段时间,若对网络中某一资源的需求超过了该资源所能提供的可用部分,网络性能就要变坏,这种情况就叫做网络拥塞。为了避免发送方无节制地发送数据,从而造成网络拥堵,所以 TCP 设计了拥塞控制。

拥塞窗口:cwnd(congestion window)是发送方维护的一个的状态变量,它会根据网络的拥塞程度动态变化的。拥塞控制的本质就是使用算法控制拥塞窗口,从而避免过多的数据注入到网络。

算法主要有四种:

  • 慢开始
  • 拥塞避免
  • 快重传
  • 快恢复

拥塞控制过程:

  1. 在 TCP 连接建立完毕后,会先使用慢启动算法,指数级逐渐增大拥塞窗口(+1 +2 +4 +8…)。
  2. 当拥塞窗口达到慢启动门限 ssthresh(slow start threshold)时,会使用拥塞避免算法,线性逐渐增大拥塞窗口。(+1 +1 +1…)
  3. 当发生超时重传或快速重传时:
    1. 如果发生超时重传,将 ssthresh 设为 cwnd/2,将 cwnd 设为初始值,然后会再次使用慢启动算法。
    2. 如果发生快速重传,使用快速恢复算法,然后进入拥塞避免阶段。

快重传:在出现分组错误后,发送方尽快重传数据,而不是等待超时计时器超时再重传。

快重传算法三个原则:

  1. 要求接收方不要等自己发送数据时才进行捎带确认,而是要立即发送确认
  2. 即使收到了失序的报文段也要立即发出对已收到的报文段的重复确认
  3. 发送方一旦收到了3个连续的重复确认,就立即将相应的报文段立即重传,不用等到超时计时器超时后再重传。

快恢复:

  • 发送方在收到3个重复确认后,就知道现在只是丢失了个别的报文段。于是不开始启动慢开始算法,转而执行快恢复算法。
  • 快恢复算法是发送方将慢开始门限ssthresh值和拥塞窗口cwnd值调整为当前窗口的一半,并开始使用拥塞避免算法
  • 有部分快恢复实现是把快恢复开始时的拥塞窗口swnd值再增大一些,即等于SSTHRESH + 3。

image-20230821214834828

发送窗口 = min(拥塞窗口,接收窗口)

2.4 端口号

任何时候,多个进程可能同时使用TCP、UDP和SCTP这3种传输层协议中的任何一种。这3 种协议都使用16位整数的端口号(port number)来区分这些进程。


当一个客户想要跟一个服务器联系时,它必须标识想要与之通信的这个服务器。TCP、UDP 和SCTP定义了一组众所周知的端口(well-known port),用于标识众所周知的服务。举例来说,支持FTP的任何TCP/IP实现都把21这个众所周知的端口分配给FTP服务器。分配给简化文件传送协议(Trivial File Trqnsfer Protocol,TFTP)的是UDP端口号69。


另一方面,客户通常使用短期存活的临时端口(ephemeral port)。这些端口号通常由传输层协议自动赋予客户。客户通常不关心其临时端口的具体值,而只需确信该端口在所在主机中是唯一的就行。传输协议的代码确保这种唯一性。


端口号被划分成以下3段。


  • 众所周知的端口为0~1023。这些端口由IANA分配和控制。可能的话,相同端口号就分配给TCP、UDP和SCTP的同一给定服务。例如,不论TCP还是UDP端口号80都被赋予Web服务器,尽管它目前的所有实现都单纯使用TCP。


  • 已登记的端口(registered port)为1024~49151。这些端口不受IANA控制,不过由IANA 登记并提供它们的使用情况清单,以方便整个群体。可能的话,相同端口号也分配给TCP和UDP 的同一给定服务。

  • 49152~65535是动态的或私用的端口。IANA不管这些端口。它们就是我们所称的临时端口。(49152这个魔数是65536的四分之三。)

image-20230328210113688

套接字对

一个TCP连接的套接字对(socket pair)是一个定义该连接的两个端点的四元组:本地IP地址、本地TCP端口号、外地IP地址、外地TCP端口号。套接字对唯一标识一个网络上的每个TCP 连接

标识每个端点的两个值(IP地址和端口号)通常称为一个套接字。


我们可以把套接字对的概念扩展到UDP,即使UDP是无连接的。当讲解套接字函数时,我们将指明它们在指定套接字对中的哪些值。举例来说,bind 函数要求应用程序给TCP、UDP或SCTP套接字指定本地IP地址和本地端口号。


2.5 TCP端口号与并发服务器

并发服务器中主服务器循环通过派生一个子进程来处理每一个新的连接。如果一个子进程继续使用服务器众所周知的端口来服务一个长时间的请求,那将发生什么?让我们来看一个典型的序列。首先,在主机 freebsd上启动服务器,该主机是多宿的,其IP地址为12.106.32.254192.168.42.1。服务器在它的众所周知的端口(本例为21)上执行被动打开,从而开始等待客户的请求,如图2-11所示。


image-20230330104459455

我们使用记号{*:21, *:*}指出服务器的套接字对。服务器在任意本地接口(第一个星号)的端口21上等待连接请求。外地IP地址和外地端口都没有指定,我们用*:*来表示。我们称它为监听套接字(listening socket)。


这里指定本地IP地址的星号称为通配(wildcard)符。如果运行服务器的主机是多宿的(如本例),服务器可以指定它只接受到达某个特定本地接口的外来连接。这里要么选一个接口要么选任意接口。服务器不能指定一个包含多个地址的清单。通配的本地地址表示“任意”这个选择。在图1-9中,通配地址通过在调用bind之前把套接字地址结构中的IP地址字段设置成INADDR_ANY(0.0.0.0)来指定。


稍后在IP地址为206.168.112.219的主机上启动第一个客户,它对服务器的IP地址之一12.106.32.254执行主动打开。我们假设本例中客户主机的TCP为此选择的临时端口为1500,如图2-12所示。图中在该客户的下方标出了它的套接字对。


image-20230330105019631

当服务器接收并接受这个客户的连接时,它fork一个自身的副本,让子进程来处理该客户的请求,如图2-13所示。

image-20230330105136119

至此,我们必须在服务器主机上区分监听套接字和已连接套接字(connected socket)。注意已连接套接字使用与监听套接字相同的本地端口(21)。还要注意在多宿服务器主机上,连接一旦建立,已连接套接字的本地地址(12.106.32.254)随即填入。

下一步我们假设在客户主机上另有一个客户请求连接到同一个服务器。客户主机的TCP为这个新客户的套接字分配一个未使用的临时端口,譬如说1501,如图2-14所示。服务器上这两个连接是有区别的:第一个连接的套接字对和第二个连接的套接字对不一样,因为客户的TCP给第二个连接选择了一个未使用的端口(1501)。


image-20230330105306388

通过本例应注意,TCP无法仅仅通过查看目的端口号来分离外来的分节到不同的端点。它必须查看套接字对的所有4个元素才能确定由哪个端点接收某个到达的分节。图2-14中对于同一个本地端口(21)存在3个套接字。如果一个分节来自206.168.112.219端口1500,目的地为12.106.32.254端口21,它就被递送给第一个子进程。如果一个分节来自206.168.112.219端口1501,目的地为12.106.32.254端口21,它就被递送给第二个子进程。所有目的端口为21的其他TCP分节都被递送给拥有监听套接字的最初那个服务器(父进程)。


2.6 缓冲区大小及限制

IP数据报的大小限制如下:

  • IPv4数据报的最大大小是65535字节,包括IPv4首部(固定20字节)。首部总长度字段占16位,则最大大小为2^16-1

image-20230330105720570

  • 
IPv6数据报的最大大小是65575字节,包括40字节的IPv6首部。这是因为如图A-2所示其净荷长度字段占据16位。注意,IPv6的净荷长度字段不包括IPv6首部,而IPv4的总长度字段包括IPv4首部。

image-20230330110013344

  • 许多网络有一个可由硬件规定的MTU(最大传输单元)。举例来说,以太网的MTU是1500字节。另有一些链路(例如使用PPP协议的点到点链路)其MTU可以人为配置。较老的SLIP链路通常使用1006字节或296字节的MTU。

    • IPv4要求的最小链路MTU是68字节。这允许最大的IPv4首部(包括20字节的固定长度部分和最多40字节的选项部分)拼接最小的片段(IPv4首部中片段偏移字段以8个字节为单位)。
    • IPv6要求的最小链路MTU为1280字节。IPv6可以运行在MTU小于此最小值的链路上,不过需要特定于链路的分片和重组功能,以使得这些链路看起来具有至少为1280 字节的MTU。

  • 在两个主机之间的路径中最小的MTU称为路径MTU(path MTU)。1500字节的以太网MTU是当今常见的路径MTU。两个主机之间相反的两个方向上路径MTU可以不一致,因为在因特网中路由选择往往是不对称的,也就是说从A到B的路径与从B到A的路径可以不相同。
  • 
当一个IP数据报将从某个接口送出时,如果它的大小超过相应链路的MTU,IPv4和IPv6 都将执行分片(fragmentation)。这些片段在到达最终目的地之前通常不会被重组(reassembling)。IPv4主机对其产生的数据报执行分片,IPv4路由器则对其转发的数据报执行分片。然而IPv6只有主机对其产生的数据报执行分片,IPv6路由器不对其转发的数据报执行分片。

暂略

2.6.1 TCP输出

图2-15展示了某个应用进程写数据到一个TCP套接字中时发生的步骤。

1
Write(connfd, buff, strlen(buff)); // buff应用进程缓冲区

image-20230330110521366

每一个TCP套接字有一个发送缓冲区,我们可以使用SO_SNDBUF套接字选项来更改该缓冲区的大小(见7.5节)。

当某个应用进程调用write时,内核从该应用进程的缓冲区中复制所有数
据到所写套接字的发送缓冲区。如果该套接字的发送缓冲区容不下该应用进程的所有数据(或是应用进程的缓冲区大于套接字的发送缓冲区,或是套接字的发送缓冲区中已有其他数据),该应用进程将被投入睡眠(阻塞)。这里假设该套接字是阻塞的,它是通常的默认设置。内核将不从write系统调用返回,直到应用进程缓冲区中的所有数据都复制到套接字发送缓冲区(可能通过多次)。因此,从写一个TCP套接字的write调用成功返回仅仅表示我们可以重新使用原来的应用进程缓冲区,并不表明对端的TCP或应用进程已接收到数据。

这一端的TCP提取套接字发送缓冲区中的数据并把它发送给对端TCP,其过程基于TCP数据传送的所有规则。对端TCP必须确认收到的数据,伴随来自对端的ACK的不断到达,本端TCP至此才能从套接字发送缓冲区中丢弃已确认的数据。TCP必须为已发送的数据保留一个副本,直到它被对端确认为止。


2.6.2 UDP输出

图2-16展示了某个应用进程写数据到一个UDP套接字中时发生的步骤。

image-20230330111321976


这一次我们以虚线框展示套接字发送缓冲区,因为它实际上并不存在。任何UDP套接字都有发送缓冲区大小(我们可以使用SO_SNDBUF套接字选项更改它),不过它仅仅是可
写到该套接字的UDP数据报的大小上限。如果一个应用进程写一个大于套接字发送缓冲区大小的数据报,内核将返回该进程一个EMSGSTZE错误。既然UDP是不可靠的,它不必保存应用进程数据的一个副本,因此无需一个真正的发送缓冲区。(应用进程的数据在沿协议栈向下传递时,通常被复制到某种格式的一个内核缓冲区中,然而当该数据被发送之后,这个副本就被数据链路层丢弃了。)


从写一个UDP套接字的write调用成功返回表示所写的数据报或其所有片段已被加入数据链路层的输出队列。如果该队列没有足够的空间存放该数据报或它的某个片段,内核通常会返回一个ENOBUFS错误给它的应用进程。


2.6.3 SCTP输出

暂略

2.7 常见因特网应用的协议使用

image-20230330111946552

3 基本套接字编程

3.1 套接字地址结构

大多数套接字函数都需要一个指向套接字地址结构的指针作为参数。每个协议族都定义它自己的套接字地址结构。这些结构的名字均以sockaddr_开头,并以对应每个协议族的唯一后缀结尾。


3.1.1 IPv4套接字地址结构

IPv4套接字地址结构以sockaddr_in命名,定义在<netinet/in.h>中,POSIX定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
struct in_addr {
in_addr_t s_addr; // 32位ip地址,网络字节序
};

// IPv4套接字地址结构
struct sockaddr_in {
uint8_t sin_len; // sockaddr_in的大小
sa_family_t sin_family; // 协议族,AF_INET
in_port_t sin_port; // 16位端口号,网络字节序
struct in_addr sin_addr; // 32位ip地址,网络字节序
char sin_zero[8]; // 未使用
};

image-20230408094311174

注意:

  • IPv4地址和端口号在套接字地址结构中总是以网络字节序来存储。
  • 32位IPv4地址存在两种不同的访问方法,如果serv定义为某个网际套接字地址结构,则:
    • serv.sin_addr将按in_addr结构引用其中的32位IPv4地址
    • serv.sin_addr.s_addr将按in_addr_t(通常是32位无符号整数)引用一个32位IPv4地址

3.1.2 通用套接字地址结构

当作为一个参数传递进任何套接字函数时,套接字地址结构总是以引用形式(也就是以指向该结构的指针)来传递。然而以这样的指针作为参数之一的任何套接字函数必须处理来自所支持的任何协议族的套接字地址结构。

在如何声明所传递指针的数据类型上存在一个问题。有了ANSI C后解决办法很简单:void *是通用的指针类型。然而套接字函数是在ANSI C之前定义的,在1982年采取的办法是在<sys/socket.h>头文件中定义一个通用的套接字地址结构sockaddr

1
2
3
4
5
struct sockaddr {
uint8_t sa_len;
sa_family_t sa_family; // 协议族,AF_XXX
char sa_data[14]; // 特定协议的地址
};

于是套接字函数被定义为以指向某个通用套接字地址结构的一个指针作为其参数之一,这正如bind函数的ANSIC函数原型所示:

1
int bind(int,struct sockaddr *,socklen_t);

这就要求对这些函数的任何调用都必须要将指向特定于协议的套接字地址结构的指针进行强制类型转换:

1
2
struct sockaddr_in serv;
bind(sockfd, (struct sockaddr *) &serv, sizeof(serv));

从应用程序开发人员的观点看,这些通用套接字地址结构的唯一用途就是对指向特定于协议的套接字地址结构的指针执行类型强制转换。

3.1.3 IPv6套接字地址结构

IPv6套接字地址结构在<netinet/in.h>头文件中定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct in6_addr {
unit8_t s6_addr[16]; /* 128-bit IPv6 address*/
/* network byte ordered */
);

#define SIN6_LEN /* required for compile-time tests */

struct sockaddr_in6 {
uint8_t sin6_len; /* length of this struct(28)*/

sa_family_t sin6_family; /* AF_INET6 */

in_port_t sin6_port;

uint32_t sin6_flowinfo; /* flow information, undefined */

struct in6_addr sin6_addr; /* IPv6 address */

uint32_t sin6_scope_id; /* set of interfaces for a scope*/

};

3.1.4 新的通用套接字地址结构

作为IPv6套接字API的一部分而定义的新的通用套接字地址结构克服了现有struct sockaddr的一些缺点。不像struct sockaddr,新的struct sockaddr_storage足以容纳系统所支持的任何套接字地址结构。sockaddr_storage结构在<netinet/in.h>头文件中定义:

1
2
3
4
struct sockaddr_storage {
uint8_t ss_len;
sa_family_t ss_family;
};

sockaddr_storage类型提供的通用套接字地址结构相比sockaddr存在以下两点差别。

  1. 如果系统支持的任何套接字地址结构有对齐需要,那么sockaddr_storage能够满足最苛刻的对齐要求。
  2. sockaddr_storage足够大,能够容纳系统支持的任何套接字地址结构。

注意,除了ss_family和ss_len外(如果有的话),sockaddr_storage结构中的其他字段对用户来说是透明的。sockaddr_storage结构必须类型强制转换成或复制到适合于ss_family字段所给出地址类型的套接字地址结构中,才能访问其他字段。

3.1.5 套接字地址结构比较

image-20230408100924841

前两种套接字地址结构是固定长度的,而Unix域结构和数据链路结构是可变长度的。为了处理长度可变的结构,当我们把指向某个套接字地址结构的指针作为一个参数传递给某个套接字函数时,也把该结构的长度作为另一个参数传递给这个函数。我们在每种长度固定的结构下方给出了这种结构的字节数长度。

3.2 值-结果参数

我们提到过,当往一个套接字函数传递一个套接字地址结构时,该结构总是以引用形式来传递,也就是说传递的是指向该结构的一个指针。该结构的长度也作为一个参数来传递,不过其传递方式取决于该结构的传递方向:是从进程到内核,还是从内核到进程。

  • 从进程到内核传递套接字地址结构的函数有3个:bindconnectsendto。这些函数的一个参数是指向某个套接字地址结构的指针,另一个参数是该结构的整数大小,例如:
1
2
struct sockaddr_in serv;
connect(sockfd, (SA *) &serv, sizeof(serv));

既然指针和指针所指内容的大小都传递给了内核,于是内核知道到底需从进程复制多少数据进来。图3-7展示了这个情形。

image-20230408101318365

此外,套接字地址结构大小的数据类型实际上是socklen_t,而不是int,不过POSIX规范建议将socklen_t定义为uint32_t

  • 从内核到进程传递套接字地址结构的函数有4个:acceptrecvfromgetsocknamegetpeername。这4个函数的其中两个参数是指向某个套接字地址结构的指针和指向表示该结构大小的整数变量的指针。例如:
1
2
3
4
5
struct sockaddr_un cli;
socklen_t len;

len = sizeof(cli);
getpeername(unixfd, (SA *) &cli, &len);

把套接字地址结构大小socklen_t len这个参数从一个整数改为指向某个整数变量的指针,其原因在于:当函数被调用时,结构大小是一个值(value),它告诉内核该结构的大小,这样内核在写该结构时不至于越界;当函数返回时,结构大小又是一个结果(result),它告诉进程内核在该结构中究竟存储了多少信息。这种类型的参数称为值-结果(value-result)参数。图3-8展示了这个情形。

image-20230408101725532

3.3 字节排序函数

小端字节序:低序字节放在起始位置。

大端字节序:高序字节放在起始位置。

image-20230408102504269

主机字节序:给定系统所用的字节序。

既然网络协议必须指定一个网络字节序(network byte order),作为网络编程人员的我们必须清楚不同字节序之间的差异。举例来说,在每个TCP分节中都有16位的端口号和32位的IPv4 地址。发送协议栈和接收协议栈必须就这些多字节字段各个字节的传送顺序达成一致。网际协议使用大端字节序来传送这些多字节整数。

从理论上说,具体实现可以按主机字节序存储套接字地址结构中的各个字段,等到需要在这些字段和协议首部相应字段之间移动时,再在主机字节序和网络字节序之间进行互转,让我们免于操心转换细节。然而由于历史的原因和POSIX规范的规定,套接字地址结构中的某些字段必须按照网络字节序进行维护。因此我们要关注如何在主机字节序和网络字节序之间相互转换。这两种字节序之间的转换使用以下4个函数:

1
2
3
4
5
6
7
8
#include <netinet/in.h>
// host -> network
uint16_t htons(uint16_t host16bitvalue);
uint32_t htonl(uint32_t host32bitvalue);

// network -> host
uint16_t ntohs(uint16_t net16bitvalue);
uint32_t ntohl(uint32_t net32bitvalue);

3.4 字节操纵函数

名字以b(表示字节)开头的第一组函数起源于4.2BSD,几乎所有现今支持套接字函数的系统仍然提供它们。名字以mem(表示内存)开头的第二组函数起源于ANSIC标准,支持ANSI C函数库的所有系统都提供它们。

我们首先给出源自Berkeley的函数:

1
2
3
4
5
6
7
#include <strings.h>
// 置0
void bzero(void *dest, size_t nbytes);
// 拷贝
void bcopy(const void *src, void *dest, size_t nbytes);
// 比较
int bcmp(const void *ptrl,const void *ptr2,size_t nbytes);

bzero把目标字节串中指定数目的字节置为0。我们经常使用该函数来把一个套接字地址结构初始化为0。

我们随后给出ANSIC函数:

1
2
3
4
#include <string.h>
void *memset(void *dest, int c, size_t len);
void *memcpy(void *dest, const void *src, size_t nbytes);
int memcmp(const void *ptrl, const void *ptr2, size_t nbytes);

menset把目标字节串指定数目len的字节置为值c

3.5 地址转换函数

本节介绍两组地址转换函数。它们在ASCII字符串(这是人们偏爱使用的格式)与网络字节序的二进制值(这是存放在套接字地址结构中的值)之间转换网际地址

  • inet_atoninet_addrinet_ntoa在点分十进制数串(例如"206.168.112.96")与它长度为32位的网络字节序二进制值间转换IPv4地址。
  • 两个较新的函数inet_ptoninet_ntop对于IPv4地址和IPv6地址都适用。这两个函数是随IPv6出现的新函数,对于IPv4地址和IPv6地址都适用。函数名中pn分别代表表达(presentation)和数值(numeric)。地址的表达格式通常是ASCII字符串,数值格式则是存放到套接字地址结构中的二进制值。
1
2
3
4
5
6
7
8
9
10
11
#include <arpa/inet.h>

// family: 协议族, AF_INET or AF_INET6
// strptr: 带转换的字符串
// addrptr: 存放结果
// 返回:若成功则为1,若输入不是有效的表达格式则为0,若出错则为-1
int inet_pton(int family, const char *strptr, void *addrptr);

// len: 目标存储单元addrptr的大小,防止溢出
// 返回:若成功则为指向结果的指针,若出错则为NULL
const char *inet_ntop(int family, const void *addrptr, char *strptr, size_t len);

image-20230408104218452

3.6 sock_ntop和相关函数

3.7 readn,writen和readline函数

这三个函数是作者自定义的函数,底层调用了readwrite两个系统调用:

1
2
3
4
5
#include "unp.h"
// 字节数, -1
ssize_t readn(int filedes,void *buff,size_t nbytes);
ssize_t written(int filedes,const void *buff,size_t nbytes);
ssize_t readline(int filedes,void *buff,size_t maxlen);

函数实现:

  • readn:每次读n个字节
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/* include readn */
#include "unp.h"

// 读取n个字节,读到vptr
ssize_t readn(int fd, void *vptr, size_t n) {
size_t nleft; // 剩余字节数
ssize_t nread; // 每次读到的字节数
char *ptr;

ptr = vptr;
nleft = n;
while(nleft > 0) {
if((nread = read(fd, ptr, nleft)) < 0) {
if (errno == EINTR) // 被信号打断
nread = 0; /* and call read() again */
else
return(-1);
} else if (nread == 0)
break; /* EOF */

nleft -= nread;
ptr += nread;
}
return(n - nleft); // 返回没读完的字节数
}
/* end readn */

ssize_t Readn(int fd, void *ptr, size_t nbytes) {
ssize_t n;

if((n = readn(fd, ptr, nbytes)) < 0)
err_sys("readn error");
return(n);
}
  • writen:写n个字节
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/* include writen */
#include "unp.h"

// 将缓冲区的前n个字节写入到fd中
ssize_t writen(int fd, const void *vptr, size_t n) {
size_t nleft;
ssize_t nwritten;
const char *ptr;

ptr = vptr;
nleft = n;
while(nleft > 0) {
if((nwritten = write(fd, ptr, nleft)) <= 0) {
if (nwritten < 0 && errno == EINTR)
nwritten = 0; /* and call write() again */
else
return(-1); /* error */
}

nleft -= nwritten;
ptr += nwritten;
}
return(n); // 返回写入的字节数
}
/* end writen */

void Writen(int fd, void *ptr, size_t nbytes) {
if (writen(fd, ptr, nbytes) != nbytes)
err_sys("writen error");
}
  • readline:从fd中读文本行,读到缓冲区中,一次1字节
1

readline.c中使用静态变量实现跨相继函数调用的状态信息维护,其结果是这些函数变得不可重入或者说非线程安全了。

4 基本TCP套接字编程

image-20230411101135549

4.1 socket函数

1
2
#include <sys/socket.h>
int socket(int family, int type, int protocol);
  • 成功返回非负描述符,错误返回-1
  • protocol:可以设置为以下常数值,或者设为0,以选择所给定family和type组合的系统默认值。
    • IPPROTO_CP:TCP传输协议
    • IPPROTO_UDP:UDP传输协议
    • IPPROTO_SCTP:SCTP传输协议

下图中空白项是无效项:

image-20230411101324362

socket函数在成功时返回一个小的非负整数值,它与文件描述符类似,我们把它称为套接字描述符(socket descriptor),简称sockfd。为了得到这个套接字描述符,我们只是指定了协议族(IPv4、IPv6、Unix)和套接字类型(字节流、数据报或原始套接字)。我们并没有指定本地协议地址或远程协议地址。

4.2 connect函数

TCP客户用connect函数来建立与TCP服务器的连接。

1
2
3
#include <sys/socket.h>
// 成功返回0,错误返回-1
int connect(int sockfd, const struct sockaddr *servaddr, socklen_t addrlen);

如果是TCP套接字,调用connect函数将激发TCP的三路握手过程,而且仅在连接建立成功或出错时才返回,其中出错返回可能有以下几种情况:

  • 若TCP客户没有收到SYN分节的响应,则返回ETIMEDOUT错误。举例来说,调用connect 函数时,4.4BSD内核发送一个SYN,若无响应则等待6s后再发送一个,若仍无响应则等待24s 后再发送一个(TCPv2第828页)。若总共等了75s后仍未收到响应则返回本错误。
  • 若对客户的SYN的响应是RST(表示复位),则表明该服务器主机在我们指定的端口上没有进程在等待与之连接(例如服务器进程也许没在运行)。这是一种硬错误(hard error),客户一接收到RST就马上返回ECONNREFUSED错误。RST是TCP在发生错误时发送的一种TCP分节。产生RST的三个条件是:
    • 目的地为某端口的SYN到达,然而该端口上没有正在监听的服务器
    • TCP想取消一个已有连接
    • TCP 接收到一个根本不存在的连接上的分节。
  • 若客户发出的SYN在中间的某个路由器上引发了一个destination unreachable(目的地不可达)ICMP错误,则认为是一种软错误(soft error)。客户主机内核保存该消息,并按第一种情况中所述的时间间隔继续发送SYN。若在某个规定的时间(4.4BSD规定75s)后仍未收到响应,则把保存的消息(即ICMP错误)作为EHOSTUNREACHENETUNREACH错误返回给进程。

4.3 bind函数

bind函数把一个本地协议地址赋予一个套接字。对于网际网协议,协议地址是32位的IPv4 地址或128位的IPv6地址与16位的TCP或UDP端口号的组合。

1
2
3
#include <sys/socket.h>
// 0, -1
int bind(int sockfd, const struct sockaddr *myaddr, socklen_t addrlen);

对于TCP,调用bind函数可以指定一个端口号,或指定一个IP地址,也可以两者都指定,还可以都不指定。

image-20230411104148258

  • 端口:服务器在启动时拥绑它们的众所周知端口。如果一个TCP客户或服务器未曾调用bind捆绑一个端口,当调用connect或listen时,内核就要为相应的套接字选择一个临时端口。让内核来选择临时端口对于TCP客户来说是正常的,除非应用需要一个预留端口;然而对于TCP服务器来说却极为罕见,因为服务器是通过它们的众所周知端口被大家认识的。

    这个规则的例外是远程过程调用(Remote Procedure Call, RPC)服务器,它们通常就由内核为它们的监听套接字选择一个临时端口,而该端口随后通过RPC端口映射器进行注册客户在connect这些服务器之前,必须与端口映射器联系以获取它们的临时端口,这种情况也适用于使用UDP的RPC服务器。

  • IP地址:进程可以把一个特定的IP地址捆绑到它的套接字上,不过这个IP地址必须属于其所在主机的网络接口之一。

    • 指定带来的影响:对于TCP客户,这就为在该套接字上发送的IP数据报指派了源IP地址。对于TCP服务器,这就限定该套接字只接收那些目的地为这个IP地址的客户连接。
    • 实际操作:TCP客户通常不把IP地址捆绑到它的套接字上。当连接套接字时,内核将根据所用外出网络接口来选择源IP地址,而所用外出接口则取决于到达服务器所需的路径。如果TCP服务器没有把IP地址捆绑到它的套接字上,内核就把客户发送的SYN的目的IP地址作为服务器的源IP地址。

通配地址:

对于IPv4来说,通配地址由常值INADDR_ANY来指定,其值一般为0。它告知内核去选择IP 地址:

1
2
struct sockarr_in servaddr;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);

对于IPv6:

1
2
struct sockaddr_in6 servaddr;
servaddr.sin6_addr = in6addr_any;

系统预先分配in6addr_any变量并将其初始化为常值IN6ADDR_ANY_INIT

无论是网络字节序还是主机字节序,INADDR_ANY的值(为0)都一样,因此使用htonl并非必需。不过既然头文件<netinet/in.h>中定义的所有INADDR_常值都是按照主机字节序定义的,我们应该对任何这些常值都使用htonl

4.4 listen函数

listen函数仅由TCP服务器调用,它做两件事情。

  • 当socket函数创建一个套接字时,它被假设为一个主动套接字,也就是说,它是一个将调用connect发起连接的客户套接字。listen函数把一个未连接的套接字转换成一个被动套接字,指示内核应接受指向该套接字的连接请求。调用listen导致套接字从CLOSED状态转换到LISTEN状态。
  • 本函数的第二个参数规定了内核应该为相应套接字排队的最大连接个数。
1
2
3
#include <sys/socket.h>
// 0, -1
int listen(int sockfd, int backlog);

为了理解其中的backlog参数,我们必须认识到内核为任何一个给定的监听套接字维护两个队列:

  • 未完成连接队列,每个这样的SYN分节对应其中一项:已由某个客户发出并到达服务器,而服务器正在等待完成相应的TCP三路握手过程。这些套接字处于SYN RCVD状态。
  • 已完成连接队列,每个已完成TCP三路握手过程的客户对应其中一项。这些套接字处于ESTABLISHED状态。

image-20230411110402940

两个队列的入队出队情况:

image-20230411110515618

当一个客户SYN到达时,若这些队列是满的,TCP就忽略该分节,也就是不发送RST。这么做是因为:这种情况是暂时的,客户TCP将重发SYN,期望不久就能在这些队列中找到可用空间。要是服务器TCP立即响应以一个RST,客户的connect调用就会立即返回一个错误,强制应用进程处理这种情况,而不是让TCP的正常重传机制来处理。另外,客户无法区别响应SYN的RST究竟意味着”该端口没有服务器在监听”,还是意味着”该端口有服务器在监听,不过它的队列满了”。

4.5 accept函数

accept函数由TCP服务器调用,用于从已完成连接队列队头返回下一个已完成连接。如果已完成连接队列为空,那么进程被投入睡眠(假定套接字为默认的阻塞方式)。

1
2
3
#include <sys/socket.h>
// fd, -1
int accept(int sockfd, struct sockaddr *cliaddr, socklen_t *addrlen);

参数cliaddraddrlen用来返回已连接的对端进程(客户)的协议地址。

如果accept成功,那么其返回值是由内核自动生成的一个全新描述符,代表与所返回客户的TCP连接。在讨论accept函数时,我们称它的第一个参数为监听套接字(listening socket)描述符(由socket创建,随后用作bind和1isten的第一个参数的描述符),称它的返回值为已连接套接字(connected socket)描述符。

区分这两个套接字非常重要。一个服务器通常仅仅创建一个监听套接字,它在该服务器的生命期内一直存在。内核为每个由服务器进程接受的客户连接创建一个已连接套接字(也就是说对于它的TCP三路握手过程已经完成)。当服务器完成对某个给定客户的服务时,相应的已连接套接字就被关闭。

本函数最多返回三个值:一个既可能是新套接字描述符也可能是出错指示的整数、客户进程的协议地址(由cliaddr指针所指)以及该地址的大小(由addrlen指针所指)。如果我们对返回客户协议地址不感兴趣,那么可以把cliaddr和addrlen均置为空指针。

程序实例

已连接套接字每次都在循环中关闭,但监听套接字在服务器的整个有效期内都保持开放。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include	"unp.h"
#include <time.h>

int main(int argc, char **argv)
{
int listenfd, connfd;
socklen_t len;
struct sockaddr_in servaddr, cliaddr;
char buff[MAXLINE];
time_t ticks;

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(13); /* daytime server */

// 强转为通用套接字地址结构
Bind(listenfd, (SA *) &servaddr, sizeof(servaddr));

// 监听,排队
Listen(listenfd, LISTENQ);

for ( ; ; ) {
len = sizeof(cliaddr);
// 从已完成连接队列里出队一个已连接套接字,队列没有则阻塞在这里
connfd = Accept(listenfd, (SA *) &cliaddr, &len);
printf("connection from %s, port %d\n",
Inet_ntop(AF_INET, &cliaddr.sin_addr, buff, sizeof(buff)),
ntohs(cliaddr.sin_port));

ticks = time(NULL);
snprintf(buff, sizeof(buff), "%.24s\r\n", ctime(&ticks));
Write(connfd, buff, strlen(buff));
// 完成任务后,关闭已连接套接字
Close(connfd);
}
}

我们可以设置accept的第二和第三个参数都是空指针,因为我们对客户的身份不感兴趣:

1
connfd = Accept(listenfd, (SA *) NULL, NULL);

4.6 并发服务器

上述服务器是一个迭代服务器(iterative server)。对于像时间获取这样的简单服务器来说,这就够了。然而当服务一个客户请求可能花费较长时间时,我们并不希望整个服务器被单个客户长期占用,而是希望同时服务多个客户。Unix中编写并发服务器程序最简单的办法就是fork一个子进程来服务每个客户。

下面给出了一个典型的并发服务器程序的轮廓:

1
2
3
4
5
6
7
8
9
10
11
12
pid_t pid;
// ...
for( ; ; ) {
connfd = Accept(listenfd, ...);
if((pid = fork()) == 0) { // 子进程代码
Close(listenfd); // 关闭子进程中的监听套接字,这个套接字是父进程的副本,关闭该套接字只会减少套接字的引用计数而不会真正关闭(即向客户端发送FIN)
doit(connfd); // 子进程任务
Close(connfd); // 关闭已连接套接字,可要可不要,因为exit后也会关闭打开的套接字描述符
exit(0); // 子进程退出
}
Close(connfd); // 关闭已连接套接字
}

image-20230411141609205

注意,此时listenfd和connfd这两个描述符都在父进程和子进程之间共享(被复制)。再下一步是由父进程关闭已连接套接字,由子进程关闭监听套接字,如图4-17所示。

image-20230411141644454

这是这两个套接字所期望的最终状态。子进程处理与客户的连接,父进程则可以在监听套接字上再次调用accept来处理下一个客户连接。

4.7 close函数

1
2
3
#include <unistd.h>
// 0, -1
int close(int sockfd);

close一个TCP套接字的默认行为是把该套接字标记成已关闭,然后立即返回到调用进程。该套接字描述符不能再由调用进程使用,也就是说它不能再作为read或write的第一个参数。然而TCP将尝试发送已排队等待发送到对端的任何数据,发送完毕后发生的是正常的TCP连接终止序列(发送FIN)。

4.8 getsockname和getpeername

这两个函数或者返回与某个套接字关联的本地协议地址getsockname),或者返回与某个套接字关联的外地协议地址getpeername)。

1
2
3
4
#include <sys/socket.h>
// 0, -1
int getsockname(int sockfd, struct sockaddr *localaddr, socklen_t *addrlen);
int getpeername(int sockfd, struct sockaddr *peeraddr, socklen_t *addrlen);

注意,这两个函数的最后一个参数都是值-结果参数。这就是说,这两个函数都得装填由localaddrpeeraddr指针所指的套接字地址结构。

获取套接字的地址族

1
2
3
4
5
6
7
8
9
10
11
12
13
#include	"unp.h"

int sockfd_to_family(int sockfd) {
// 为最大的套接字地址结构分配空间
// 既然不知道要分配的套接字地址结构的类型,我们于是采用sockaddr_storage这个通用结构,因为它能够承载系统支持的任何套接字地址结构。
struct sockaddr_storage ss;
socklen_t len;

len = sizeof(ss);
if (getsockname(sockfd, (SA *) &ss, &len) < 0)
return(-1);
return(ss.ss_family);
}

4.9 小结

所有客户和服务器都从调用socket开始,它返回一个套接字描述符。客户随后调用connect,服务器则调用bindlistenaccept。套接字通常使用标准的close函数关闭,不过我们将看到使用shutdown函数关闭套接字的另一种方法,我们还要查看SO_LINGER套接字选项对于关闭套接字的影响。

大多数TCP服务器是并发的,它们为每个待处理的客户连接调用fork派生一个子进程。我们将看到,大多数UDP服务器却是迭代的。尽管这两个模型已经成功地运用了许多年,我们仍将在后面中探讨使用线程和进程的其他服务器程序设计方法。

5 TCP客户/服务器程序示例

5.1 概述

image-20230411143039421

这个简单的例子是执行如下步骤的一个回射服务器:

  • 客户从标准输入读入一行文本,并写给服务器;
  • 服务器从网络输入读入这行文本,并回射给客户;
  • 客户从网络输入读入这行回射文本,并显示在标准输出上

5.2 服务器程序

5.2.1 main函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include	"unp.h"

int main(int argc, char **argv) {
int listenfd, connfd;
pid_t childpid;
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(SERV_PORT);
// 将监听套接字绑定本机地址
Bind(listenfd, (SA *) &servaddr, sizeof(servaddr));

Listen(listenfd, LISTENQ);

for( ; ; ) {
clilen = sizeof(cliaddr);
connfd = Accept(listenfd, (SA *) &cliaddr, &clilen);

if((childpid = Fork()) == 0) { /* child process */
Close(listenfd); /* close listening socket */
str_echo(connfd); /* process the request */
exit(0);
}
Close(connfd); /* parent closes connected socket */
}
}

5.2.2 str_echo函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include	"unp.h"
// 回射函数
void str_echo(int sockfd) {
ssize_t n;
char buf[MAXLINE];

again:
while((n = read(sockfd, buf, MAXLINE)) > 0) // 从副套接字中读n个字节到buf
Writen(sockfd, buf, n); // 又将读到的n个字节写回到副套接字中(回射)

if(n < 0 && errno == EINTR)
goto again;
else if (n < 0)
err_sys("str_echo: read error");
// n == 0时从函数返回
}

5.3 客户程序

5.3.1 main函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include	"unp.h"

int main(int argc, char **argv) {
int sockfd;
struct sockaddr_in servaddr;

if (argc != 2)
err_quit("usage: tcpcli <IPaddress>");
// IPv4,流式套接字,默认使用TCP
sockfd = Socket(AF_INET, SOCK_STREAM, 0);
// 设置对端地址
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(SERV_PORT); // 9877

// bind(sockfd, (SA *) &cliaddr, sizeof(cliaddr))

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

// 发起TCP连接
Connect(sockfd, (SA *) &servaddr, sizeof(servaddr));

str_cli(stdin, sockfd); /* do it all */

exit(0);
}

5.3.2 str_cli函数

str_cli函数完成客户处理循环:从标准输入读入一行文本,写到服务器上,读回服务器对该行的回射,并把回射行写到标准输出上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include	"unp.h"

void str_cli(FILE *fp, int sockfd) {
char sendline[MAXLINE], recvline[MAXLINE];
// 从fp中读一行到sendline中,最大读取MAXLINE-1个字节
while(Fgets(sendline, MAXLINE, fp) != NULL) {
// 写入到sockfd
Writen(sockfd, sendline, strlen(sendline));
// 读取sockfd
if(Readline(sockfd, recvline, MAXLINE) == 0)
err_quit("str_cli: server terminated prematurely");
// 打印在标准输出
Fputs(recvline, stdout);
}
}

5.4 正常行为

5.4.1 正常启动

  • 启动服务器,并查看连接,也可以用-ant只查看tap连接
1
netstat -a

image-20230416113647796

这个输出正是我们所期望的:有一个套接字处于LISTEN状态,它有通配的本地IP地址,本地端口为9877。netstat用星号*来表示一个为0的IP地址(INADDR_ANY,通配地址)或为0的端口号。

  • 启动客户端
1
./tcpcli01 127.0.0.1

至此,我们有3个都在睡眠(即已阻塞)的进程:客户进程、服务器父进程和服务器子进程。

image-20230416114151080

5.4.2 正常终止

至此连接已经建立,不论我们在客户的标准输入中键入什么,都会回射到它的标准输出中:

1
2
3
4
5
[root@HongyiZeng tcpcliserv]# ./tcpcli01 127.0.0.1
hello, world
hello, world
good bye
good bye

接着键入终端字符(Ctrl-D)关闭客户端,此时立即查看tcp连接:

image-20230416114611797

当前连接的客户端(它的本地端口号为42758)进入了TIME_WAIT状态,而监听服务器仍在等待另一个客户连接。

我们可以总结出正常终止客户和服务器的步骤。

  1. 当我们键入EOF字符时,fgets返回一个空指针,于是str_cli函数返回。
  2. 当str_cli返回到客户的main函数时,main通过调用exit终止。
  3. 进程终止处理的部分工作是关闭所有打开的描述符,因此客户打开的套接字由内核关闭。这导致客户TCP发送一个FIN给服务器,服务器TCP则以ACK响应,这就是TCP连接终止序列的前半部分。至此,服务器套接字处于CLOSE_WAIT状态,客户套接字则处于FIN_WAIT_2 状态
  4. 当服务器TCP接收FIN时,服务器子进程阻塞于readline调用,于是readline返回0。这导致str_echo函数返回服务器子进程的main函数。
  5. 服务器子进程通过调用exit来终止。
  6. 服务器子进程中打开的所有描述符随之关闭。由子进程来关闭已连接套接字会引发TCP连接终止序列的最后两个分节:一个从服务器到客户的FIN和一个从客户到服务器的ACK。至此,连接完全终止,客户套接字进入TIME_WAIT状态。
  7. 进程终止处理的另一部分内容是:在服务器子进程终止时,给父进程发送一个SIGCHLD信号。这一点在本例中发生了,但是我们没有在代码中捕获该信号,而该信号的默认行为是被忽略。既然父进程未加处理,子进程于是进入僵死状态(父进程没有给子进程收尸)。

5.5 POSIX信号处理

5.5.1 signal和sigaction函数

信号(signal)就是告知某个进程发生了某个事件的通知,有时也称为软件中断。信号通常是异步发生的,也就是说进程预先不知道信号的准确发生时刻。

信号的来源:

  • 由一个进程发给另一个进程(或自身);例如子进程退出后会向父进程发送SIGCHLD信号
  • 由内核发给某个进程。

信号的处理:处理(捕获),忽略,默认动作(终止并生成核心转储文件或忽略)

  • 可以提供一个函数,只要有特定信号发生它就被调用。这样的函数称为信号处理函数(signal handler),这种行为称为捕获(catching)信号。有两个信号不能被捕获,它们是SIGKILLSIGSTOP。信号处理函数由信号值这个单一的整数参数来调用,且没有返回值,其函数原型因此如下:
1
void handler(int signo);
  • 可以把某个信号的处置设定为SIG_IGN来忽略它。

  • 可以把某个信号的处置设定为SIG_DFL来启用它的默认处置。默认处置通常是在收到信号后终止进程,其中某些信号还在当前工作目录产生一个进程的核心映像(core image,也称为内存影像)。另有个别信号的默认处置是忽略。

signal的包裹函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/* include signal */
#include "unp.h"

Sigfunc *signal(int signo, Sigfunc *func) {
struct sigaction act, oact;

act.sa_handler = func;
sigemptyset(&act.sa_mask); // 解除对所有信号的阻塞(可被任意信号中断)
act.sa_flags = 0; // 无特殊要求
if(signo == SIGALRM) { // 对SIGALRM信号进行特殊处理
#ifdef SA_INTERRUPT
act.sa_flags |= SA_INTERRUPT; /* SunOS 4.x */
#endif
} else {
#ifdef SA_RESTART
act.sa_flags |= SA_RESTART; /* SVR4, 44BSD */
#endif
}
// 注册
if(sigaction(signo, &act, &oact) < 0)
return(SIG_ERR);
// 返回原来的处理句柄
return(oact.sa_handler);
}
/* end signal */

// 对信号signo注册信号处理函数func
Sigfunc *Signal(int signo, Sigfunc *func) { /* for our signal() function */
Sigfunc *sigfunc;

if((sigfunc = signal(signo, func)) == SIG_ERR)
err_sys("signal error");
return(sigfunc);
}

程序说明:

  • 对SIGALRM信号进行特殊处理的原因:SIGALRM是在定时器终止时发送给进程的信号,使用SIGALRM作为长时间操作的超时信号,或提供一种隔一定时间间隔处理某些操作的方式。在进行阻塞式系统调用(文件IO、套接字IO)时,为避免进程陷入无限期的等待,可以为这些阻塞式系统调用设置定时器(见12.1.1小节)。

  • 信号可以打断阻塞的系统调用,导致系统调用函数提前返回。标志位SA_RESTART(有些linux版本使用SA_INTERRUPT):如果信号中断了进程的某个系统调用,则系统可以自动启动该系统调用,而不是返回。注意有些系统可以自动重启被中断的系统调用。

5.5.2 处理SIGCHLD信号

服务器在listen调用之后增加如下函数调用:

1
2
// 为SIGCHLD信号注册sig_chld处理函数
Signal(SIGCHLD, sig_chld);

sig_chld处理函数实现如下:

1
2
3
4
5
6
7
8
9
10
#include	"unp.h"

void sig_chld(int signo) {
pid_t pid;
int stat;

pid = wait(&stat);
printf("child %d terminated\n", pid);
return;
}

警告:在信号处理函数中调用诸如printf这样的标准I/O函数是不合适的,其原因将在11.18节讨论。我们在这里调用printf只是作为查看子进程何时终止的诊断手段。

在System V和Unix 98标准下,如果一个进程把SIGCHLD的处置设定为SIG_IGN,它的子进程就不会变为僵死进程。不幸的是,这种做法仅仅适用于System V和Unix 98,而POSIX明确表示没有规定这样做。处理僵死进程的可移植方法就是捕获SIGCHLD,并调用wait或waitpid

实例

1
2
3
4
5
6
7
[root@HongyiZeng tcpcliserv]# ./tcpserv02 &
[1] 25997
[root@HongyiZeng tcpcliserv]# ./tcpcli01 127.0.0.1
hi
hi
child 26096 terminated
accept error: Interrupted system call # Solaris 9环境

具体的各个步骤如下:

  • 键入EOF字符来终止客户。客户TCP发送一个FIN给服务器,服务器响应以一个ACK。
  • 收到客户的FIN导致服务器TCP递送一个EOF给子进程阻塞中的readline,从而子进程终止。
  • 当SIGCHLD信号递交时,父进程正阻塞于accept调用。sig_chld函数(信号处理函数)执行,其wait调用取到子进程的PID和终止状态,随后是printf调用,最后返回。
  • 既然该信号是在父进程阻塞于慢系统调用(accept)时由父进程捕获的(信号可以打断阻塞的系统调用),内核就会使accept返回一个EINTR错误(被中断的系统调用)。而父进程不处理该错误,于是中止。

这个例子是为了说明,在编写捕获信号的网络程序时,我们必须认清被中断的系统调用且处理它们。在这个运行在Solaris 9环境下特定例子中,标准C函数库中提供的signal函数不会使内核自动重启被中断的系统调用。也就是说,我们设置的SA_RESTART标志在系统函数库的signal函数中并没有设置。另有些系统自动重启被中断的系统调用。如果我们在4.4BSD环境下照样使用系统函数库版本的signal函数运行上述例子,那么内核将重启被中断的系统调用,于是accept不会返回错误。我们定义自己的signal函数并在贯穿全书使用的理由之一就是应对不同操作系统之间的这个潜在问题。


处理被中断的系统调用:

我们用术语慢系统调用描述过accept函数,该术语也适用于那些可能永远阻塞的系统调用。永远阻塞的系统调用是指调用有可能永远无法返回,多数网络支持函数都属于这一类。举例来说,如果没有客户连接到服务器上,那么服务器的accept调用就没有返回的保证。

适用于慢系统调用的基本规则是:当阻塞于某个慢系统调用的一个进程捕获某个信号且相应信号处理函数返回时,该系统调用可能返回一个EINTR错误。有些内核自动重启某些被中断的系统调用。不过为了便于移植,当我们编写捕获信号的程序时(多数并发服务器捕获SIGCHLD),我们必须对慢系统调用返回EINTR有所准备。

为了处理被信号中断的accept,需要修改代码为:

1
2
3
4
5
6
7
8
9
10
for( ; ; ) {
clilen = sizeof(cliaddr);
if((connfd = Accept(listenfd, (SA *) &cliaddr, &clilen)) < 0) {
if(errno == EINTR)
continue;
else
err_sys("accept error");
}
// ...
}

5.5.3 wait和waitpid函数

image-20230416150847446

image-20230416150856316

为了避免遗留下僵尸进程,需要修改sig_chld处理函数为:

1
2
3
4
5
6
7
8
9
10
11
#include	"unp.h"

void sig_chld(int signo) {
pid_t pid;
int stat;
// -1:等待任何一个子进程退出,此时waitpid和wait的作用一模一样
// WNOHANG:告知内核在没有已终止子进程时不要阻塞。
while((pid = waitpid(-1, &stat, WNOHANG)) > 0)
printf("child %d terminated\n", pid);
return;
}

TCP服务器代码的最终版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include	"unp.h"

int main(int argc, char **argv) {
int listenfd, connfd;
pid_t childpid;
socklen_t clilen;
struct sockaddr_in cliaddr, servaddr;
void sig_chld(int);

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(SERV_PORT);

Bind(listenfd, (SA *) &servaddr, sizeof(servaddr));

Listen(listenfd, LISTENQ);
// 注册处理函数
Signal(SIGCHLD, sig_chld); /* must call waitpid() */

for ( ; ; ) {
clilen = sizeof(cliaddr);
if ( (connfd = accept(listenfd, (SA *) &cliaddr, &clilen)) < 0) {
if (errno == EINTR) // 被信号打断
continue; /* back to for() */
else
err_sys("accept error");
}

if ( (childpid = Fork()) == 0) { /* child process */
Close(listenfd); /* close listening socket */
str_echo(connfd); /* process the request */
exit(0);
}
Close(connfd); /* parent closes connected socket */
}
}

小结:

  1. 当fork子进程时,必须捕获SIGCHLD信号;
  2. 当捕获信号时,必须处理被打断的系统调用:
  3. SIGCHLD的信号处理函数必须正确编写,应使用waitpid函数以免留下僵死进程。

5.6 异常行为

5.6.1 服务器子进程终止

现在启动我们的客户/服务器对,然后杀死服务器子进程。这是在模拟服务器进程崩溃的情形,我们可从中查看客户将发生什么。所发生的步骤如下所述:

  • 我们在同一个主机上启动服务器和客户,并在客户上键入一行文本,以验证一切正常。正常情况下该行文本由服务器子进程回射给客户。
  • 找到服务器子进程的进程ID,并执行kill命令杀死它。作为进程终止处理的部分工作,子进程中所有打开着的描述符都被关闭。这就导致向客户发送一个FIN,而客户TCP则响应以一个ACK。这就是TCP连接终止工作的前半部分。
  • SIGCHLD信号被发送给服务器父进程,并得到正确处理。
  • 客户上没有发生任何特殊之事。客户TCP接收来自服务器TCP的FIN并响应以一个ACK,然而问题是客户进程阻塞在fgets调用上,等待从终端接收一行文本。
1
2
3
while(Fgets(sendline, MAXLINE, fp) != NULL) {
// ...
}
  • 观察套接字的状态:可以看出TCP终止过程的前半部分已经完成。服务器处于FIN_WAIT_2,等待客户端关闭,而客户端处于CLOSE_WAIT,尚未关闭,并且此时阻塞在fgets。

image-20230417090411955

  • 我们可以在客户上再键入一行文本。以下是从第一步开始发生在客户之事:

image-20230417090454390

当我们键入another line时,str_cli调用writen,客户TCP接着把数据发送给服务器。TCP允许这么做,因为客户TCP接收到FIN只是表示服务器进程已关闭了连接的服务器端,从而不再往其中发送任何数据而已。FIN的接收并没有告知客户TCP服务器进程已经终止(本例子中它确实是终止了)。

当服务器TCP接收到来自客户的数据时,既然先前打开那个套接字的进程已经终止,于是响应以一个RST。通过使用tcpdump来观察分组,我们可以验证该RST确实发送了。

  • 然而客户进程看不到这个RST,因为它在调用writen后立即调用readline,并且由于第2步中接收的FIN,所调用的readline立即返回0(表示EOF)。我们的客户此时并未预期收到EOF,于是以出错信息server terminated prematurely(服务器过早终止)退出。
1
2
if(Readline(sockfd, recvline, MAXLINE) == 0)
err_quit("str_cli: server terminated prematurely");
  • 当客户终止时,它所有打开着的描述符都被关闭。

本例子的问题在于:当FIN到达套接字时,客户正阻塞在fgets调用上。客户实际上在应对两个描述符:套接字和用户输入,它不能单纯阻塞在这两个源中某个特定源的输入上(正如目前编写的str_cli函数所为),而是应该阻塞在其中任何一个源的输入上。事实上这正是select和poll这两个函数的目的之一。

5.6.2 SIGPIPE信号

要是客户不理会readline函数返回的错误,反而写入更多的数据到服务器上,那又会发生什么呢?这种情况是可能发生的,举例来说,客户可能在读回任何数据之前执行两次针对服务器的写操作,而RST是由其中第一次写操作引发的。

适用于此的规则是:当一个进程向某个已收到RST的套接字执行写操作时,内核向该进程发送一个SIGPIPE信号。该信号的默认行为是终止进程,因此进程必须捕获它以免不情愿地被终止。

不论该进程是捕获了该信号并从其信号处理函数返回,还是简单地忽略该信号,写操作都将返回EPIPE错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include	"unp.h"

void str_cli(FILE *fp, int sockfd) {
char sendline[MAXLINE], recvline[MAXLINE];

while(Fgets(sendline, MAXLINE, fp) != NULL) {

Writen(sockfd, sendline, 1);
sleep(1); // 睡眠1s
Writen(sockfd, sendline+1, strlen(sendline)-1); // 再次向套接字写入数据

if(Readline(sockfd, recvline, MAXLINE) == 0)
err_quit("str_cli: server terminated prematurely");

Fputs(recvline, stdout);
}
}

我们所做的修改就是调用writen两次:第一次把文本行数据的第一个字节写入套接字,暂停一秒钟后,第二次把同一文本行中剩余字节写入套接字。目的是让第一次writen引发一个RST,再让第二个writen产生SIGPIPE。在我们的Linux主机上运行客户,我们得到如下结果:

1
2
3
4
5
6
linux % tcpclill 127.0.0.1
hi there # 我们键入这行文本
hi there # 它被服务器回射回来
# 在这儿杀死服务器子进程
bye # 然后键入这行文本
Broken pipe # 本行由shell显示

5.6.3 服务器主机崩溃

  • 当服务器主机崩溃时,已有的网络连接上不发出任何东西。这里我们假设的是主机崩溃,而不是由操作员执行命令关机。
  • 我们在客户上键入一行文本,它由writen写入内核,再由客户TCP作为一个数据分节送出。客户随后阻塞于readline调用,等待回射的应答。
  • 如果我们用tcpdump观察网络就会发现,客户TCP持续重传数据分节,试图从服务器上接收一个ACK。给出TCP重传一个典型模式:源自Berkeley的实现重传该数据分节12次,共等待约9分钟才放弃重传。当客户TCP最后终于放弃时(假设在这段时间内,服务器主机没有重新启动,或者如果是服务器主机未崩溃但是从网络上不可达,那么假设主机仍然不可达),给客户进程返回一个错误。既然客户阻塞在readline调用上,该调用将返回一个错误。假设服务器主机已崩溃,从而对客户的数据分节根本没有响应,那么所返回的错误是ETDMEDOUT。然而如果某个中间路由器判定服务器主机已不可达,从而响应以一个”destination unreachable”(目的地不可达)ICMP消息,那么所返回的错误是EHOSTUNREACHENETUNREACH

尽管我们的客户最终还是会发现对端主机已崩溃或不可达,不过有时候我们需要比不得不等待9分钟更快地检测出这种情况。所用方法就是对readline调用设置一个超时。

我们刚刚讨论的情形只有在我们向服务器主机发送数据时才能检测出它已经崩溃。如果我们不主动向它发送数据也想检测出服务器主机的崩溃,那么需要采用另外一个技术,即使用SO_KEEPALIVE套接字选项。

5.7 服务器主机关机

Unix系统关机时,init进程通常先给所有进程发送SIGTERM信号(该信号可被捕获),等待一段固定的时间(往往在5到20秒之间),然后给所有仍在运行的进程发送SIGKTLL信号(该信号不能被捕获)。这么做留给所有运行的进程一小段时间来清除和终止。如果我们不捕获SIGTERM信号并终止,我们的服务器将由SIGKILL信号终止。当服务器子进程终止时,它的所有打开着的描述符都被关闭,随后发生的步骤与5.11节中讨论过的一样。正如那一节所述,我们必须在客户中使用select或poll函数,使得服务器进程的终止一经发生,客户就能检测到。

5.8 数据格式

在我们的例子中,服务器从不检查来自客户的请求。它只管读入直到换行符(包括换行符)的所有数据,把它发回给客户,所搜索的仅仅是换行符。这只是一个例外,而不是通常规则,一般来说,我们必须关心在客户和服务器之间进行交换的数据的格式。

暂略

6 IO多路复用

6.1 概述

我们看到TCP客户同时处理两个输入:标准输入和TCP套接字。我们遇到的问题是就在客户阻塞于(标准输入上的)fgets调用期间,服务器进程会被杀死。服务器TCP虽然正确地给客户TCP发送了一个FIN,但是既然客户进程正阻塞于从标准输入读入的过程,它将看不到这个EOF,直到从套接字读时为止(可能已过了很长时间)。

这样的进程需要一种预先告知内核的能力,使得内核一旦发现进程指定的一个或多个I/O条件就绪(也就是说输入已准备好被读取,或者描述符已能承接更多的输出),它就通知进程。这个能力称为I/O复用(I/O multiplexing),是由select和poll这两个函数支持的。我们还介绍前者较新的称为pselect的POSIX变种。

I/O复用典型使用在下列网络应用场合。

  • 当客户处理多个描述符(通常是交互式输入和网络套接字)时,必须使用I/O复用。
  • 一个客户同时处理多个套接字是可能的,不过比较少见。
  • 如果一个TCP服务器既要处理监听套接字,又要处理已连接套接字,一般就要使用I/O复用。
  • 如果一个服务器即要处理TCP,又要处理UDP,一般就要使用I/O复用。
  • 如果一个服务器要处理多个服务或者多个协议(例如inetd守护进程),一般就要使用I/O复用。

I/O复用并非只限于网络编程,许多重要的应用程序也需要使用这项技术。

6.2 IO模型

Unix下可用的五种IO模型:

  1. 阻塞IO
  2. 非阻塞IO
  3. IO复用(select,pselect,poll,epoll)
  4. 信号驱动IO(SIGIO
  5. 异步IO(POSIX的aio_系列函数)

一个输入操作通常包括两个不同的阶段:

  1. 等待数据准备好;
  2. 从内核向进程复制数据。

对于一个套接字上的输入操作,第一步通常涉及等待数据从网络中到达。当所等待分组到达时,它被复制到内核中的某个缓冲区。第二步就是把数据从内核缓冲区复制到应用进程缓冲区。

6.2.1 阻塞IO

默认情形下,所有套接字都是阻塞的。

image-20230417095643366

在图6-1中,进程调用recvfrom,其系统调用直到数据报到达且被复制到应用进程的缓冲区中或者发生错误才返回。最常见的错误是系统调用被信号中断,如5.5节所述。我们说进程在从调用recvfrom开始到它返回的整段时间内是被阻塞的。recvfrom成功返回后,应用进程开始处理数据报。

6.2.2 非阻塞IO

image-20230417095900469

前三次调用recvfrom时没有数据可返回,因此内核转而立即返回一个EWOULDBLOCK错误。第四次调用recvfrom时已有一个数据报准备好,它被复制到应用进程缓冲区,于是recvfrom 成功返回。我们接着处理数据。

当一个应用进程像这样对一个非阻塞描述符循环调用recvfrom时,我们称之为轮询(polling)。应用进程持续轮询内核,以查看某个操作是否就绪。这么做往往耗费大量CPU时间,不过这种模型偶尔也会遇到,通常是在专门提供某一种功能的系统中才有。

6.2.3 IO复用

image-20230417100128922比较图6-3和图6-1,I/O复用并不显得有什么优势,事实上由于使用select需要两个而不是单个系统调用,I/O复用还稍有劣势。不过我们将在本章稍后看到,使用select的优势在于我们可以等待多个描述符就绪。

与I/O复用密切相关的另一种I/O模型是在多线程中使用阻塞式I/O。这种模型与上述模型极为相似,但它没有使用select阻塞在多个文件描述符上,而是使用多个线程(每个文件描述符一个线程),这样每个线程都可以自由地调用诸如recvfrom之类的阻塞式I/O系统调用了。

6.2.4 信号驱动IO

我们也可以用信号,让内核在描述符就绪时发送SIGIO信号通知我们。我们称这种模型为信号驱动式I/O(signal-driven I/O),图6-4是它的概要展示。

image-20230417100330045

无论如何处理SIGIO信号,这种模型的优势在于等待数据报到达期间进程不被阻塞。主循环可以继续执行,只要等待来自信号处理函数的通知:既可以是数据已准备好被处理,也可以是数据报已准备好被读取。

6.2.5 异步IO

异步IO由POXIS规范定义。

image-20230417100519183

一般地说,这些函数的工作机制是:告知内核启动某个操作,并让内核在整个操作(包括将数据从内核复制到我们自己的缓冲区)完成后通知我们。这种模型与前一节介绍的信号驱动模型的主要区别在于:信号驱动式I/O是由内核通知我们何时可以启动一个I/O操作,而异步I/O模型是由内核通知我们I/O操作何时完成。图6-5给出了一个例子。

我们调用aio_read函数(POSIX异步I/O函数以aio_lio_开头),给内核传递描述符、缓冲区指针、缓冲区大小(与read相同的三个参数)和文件偏移(与lseek类似),并告诉内核当整个操作完成时如何通知我们。该系统调用立即返回,而且在等待I/O完成期间,我们的进程不被阻塞。本例子中我们假设要求内核在操作完成时产生某个信号。该信号直到数据已复制到应用进程缓冲区才产生,这一点不同于信号驱动式I/O模型。

本书编写至此的时候,支持POSIX异步I/O模型的系统仍较罕见。我们不能确定这样的系统是否支持套接字上的这种模型。这儿我们只是用它作为一个与信号驱动式I/O模型相比照的例子。

6.2.6 模型对比

POSIX把这两个术语定义如下:

  • 同步I/O操作(synchronous I/O opetation)导致请求进程阻塞,直到I/O操作完成;
  • 异步I/O操作(asynchronous I/O opetation)不导致请求进程阻塞。

image-20230417101516436

根据上述定义,我们的前4种模型——阻塞式I/O模型、非阻塞式I/O模型、I/O复用模型和信号驱动式I/O模型都是同步I/O模型,因为其中真正的I/O操作(recvfrom)将阻塞进程。只有异步I/O模型与POSIX定义的异步I/O相匹配。

6.3 select函数

该函数允许进程指示内核等待多个事件中的任何一个发生,并只在有一个或多个事件发生或经历一段指定的时间后才唤醒它。

作为一个例子,我们可以调用select,告知内核仅在下列情况发生时才返回:

  • 集合{1,4,5}中的任何描述符准备好读;
  • 集合{2,7}中的任何描述符准备好写;
  • 集合{1,4}中的任何描述符有异常条件待处理;
  • 已经历了10.2秒。

也就是说,我们调用select告知内核对哪些描述符(就读、写或异常条件)感兴趣以及等待多长时间。我们感兴趣的描述符不局限于套接字,任何描述符都可以使用select来测试。

6.3.1 函数原型

1
2
3
4
5
#include <sys/select.h>
#include <sys/time.h>

// 准备就绪的描述符个数, 超时0, 出错-1
int select(int maxfdpl, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
  • struct timeval *timeout有三种可能:
    • 永远等待:NULL
    • 等待固定时间
    • 不等待(轮询):0
  • fd_set *readfds, fd_set *writefds, fd_set *exceptfds:指定要让内核分别测试读、写和异常条件的描述符集合

所有这些实现细节都与应用程序无关,它们隐藏在名为fd_set的数据类型和以下四个宏中:

1
2
3
4
5
6
7
8
// 删除 set 中的给定的文件描述符
void FD_CLR(int fd, fd_set *set);
// 测试文件描述符 fd 是否在 set 集合中
int FD_ISSET(int fd, fd_set *set);
// 将文件描述符 fd 添加到 set 中
void FD_SET(int fd, fd_set *set);
// 清空 set 中的文件描述符
void FD_ZERO(fd_set *set);
  • maxfdp1:指定待测试的描述符个数,它的值是待测试的最大描述符加1,描述符0,1,2…一直到maxfdp1-1(即待测试的最大描述符)均将被测试。

select函数修改由指针readset、writeset和exceptset所指向的描述符集,因而这三个参数都是值-结果参数。调用该函数时,我们指定所关心的描述符的值,该函数返回时,结果将指示哪些描述符已就绪。该函数返回后,我们使用FD_ISSET宏来测试fd_set数据类型中的描述符。描述符集内任何与未就绪描述符对应的位返回时均清成0。为此,每次重新调用select函数时,我们都得再次把所有描述符集内所关心的位均置为1。

6.3.2 描述符就绪条件

image-20230417104110702

套接字准备好读

  • 该套接字接收缓冲区(内核)中的数据字节数大于等于套接字接收缓冲区低水位标记的当前大小。对这样的套接字执行读操作不会阻塞并将返回一个大于0的值(也就是返回准备好读入的数据)。我们可以使用SO_RCVLOWAT套接字选项设置该套接字的低水位标记。对于TCP和UDP套接字而言,其默认值为1。
  • 该连接的读半部关闭(也就是接收了FIN的TCP连接的套接字)。对这样的套接字的读操作将不阻塞并返回0(也就是返回EOF),见5.6.1节。
  • 该套接字是一个监听套接字且已完成的连接数不为0。对这样的套接字的accept通常不会阻塞。
  • 其上有一个套接字错误待处理。对这样的套接字的读操作将不阻塞并返回-1(也就是返回一个错误),同时把errno设置成确切的错误条件。

套接字准备好写

  • 该套接字发送缓冲区中的可用空间字节数大于等于套接字发送缓冲区低水位标记的当前大小,并且或者该套接字已连接,或者该套接字不需要连接(如UDP套接字)。这意味着如果我们把这样的套接字设置成非阻塞,写操作将不阻塞并返回一个正值(例如由传输层接受的字节数)。我们可以使用SO_SNDLOWAT套接字选项来设置该套接字的低水位标记。对于TCP 和UDP套接字而言,其默认值通常为2048。
  • 该连接的写半部关闭(主动关闭套接字的一方再次向套接字写入数据)。对这样的套接字的写操作将产生SIGPIPE信号,见5.6.2节。
  • 使用非阻塞式connect的套接字已建立连接,或者connect已经以失败告终。
  • 其上有一个套接字错误待处理。对这样的套接字的写操作将不阻塞并返回-1(也就是返回一个错误),同时把errno设置成确切的错误条件。

异常

如果一个套接字存在带外数据或者仍处于带外标记,那么它有异常条件待处理。

注意:当某个套接字上发生错误时,它将由select标记为既可读又可写。


接收低水位标记和发送低水位标记的目的在于:允许应用进程控制在select返回可读或可写条件之前有多少数据可读或有多大空间可用于写。举例来说,如果我们知道除非至少存在64 个字节的数据,否则我们的应用进程没有任何有效工作可做,那么可以把接收低水位标记设置为64,以防少于64个字节的数据准备好读时select唤醒我们。

任何UDP套接字只要其发送低水位标记小于等于发送缓冲区大小(默认应该总是这种关系)就总是可写的,这是因为UDP套接字不需要连接。

6.4 修订版str_cli函数

现在我们可以使用select重写str_cli函数了,这样服务器进程一终止,客户就能马上得到通知。早先那个版本的问题在于:当套接字上发生某些事件时(例如TCP套接字接收到FIN而准备好读时),客户可能阻塞于fgets调用。新版本改为阻塞于select调用,或是等待标准输入可读,或是等待套接字可读。图6-8展示了调用select所处理的各种条件。

image-20230417105221238

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include	"unp.h"

void str_cli(FILE *fp, int sockfd) {
int maxfdp1;
fd_set rset;
char sendline[MAXLINE], recvline[MAXLINE];
// 一定要先清空后续需要操作的描述符集合
FD_ZERO(&rset);
for( ; ; ) {
FD_SET(fileno(fp), &rset); // 监控标准输入的读就绪
FD_SET(sockfd, &rset); // 监控套接字的读就绪
maxfdp1 = max(fileno(fp), sockfd) + 1;
Select(maxfdp1, &rset, NULL, NULL, NULL); // 阻塞到某个描述符就绪为止

if(FD_ISSET(sockfd, &rset)) { /* socket is readable */
if(Readline(sockfd, recvline, MAXLINE) == 0)
err_quit("str_cli: server terminated prematurely");
Fputs(recvline, stdout);
}

if(FD_ISSET(fileno(fp), &rset)) { /* input is readable */
if(Fgets(sendline, MAXLINE, fp) == NULL)
return; /* all done */
// 可能会阻塞(如果套接字发送缓冲区满)
Writen(sockfd, sendline, strlen(sendline));
}
}
}

fileno:把文件流指针转换成文件描述符。

6.5 批量输入

暂略

6.6 shutdown函数

终止网络连接的通常方法是调用close函数。不过close有两个限制,却可以使用shutdown来避免。

  1. close把描述符的引用计数减1,仅在该计数变为0时才关闭套接字。使用shutdown可以不管引用计数就激发TCP的正常连接终止序列。
  2. close同时终止这一端读和写两个方向的数据传送。既然TCP连接是全双工的,有时候我们需要告知对端我们已经完成了数据发送,即使对端仍有数据要发送给我们。这就是我们在前一节中遇到的str_cli函数在批量输入时的情况。图6-12展示了这样的情况下典型的函数调用。

image-20230417110724638

1
2
#include <sys/socket.h>
int shutdown(int sockfd, int howto);

该函数的行为依赖于howto参数的值:

  • SHUT_RD:关闭连接的读这一半。套接字中不再有数据可接收,而且套接字接收缓冲区中的现有数据都被丢弃。进程不能再对这样的套接字调用任何读函数。对一个TCP套接字这样调用shutdown函数后,由该套接字接收的来自对端的任何数据都被确认,然后悄然丢弃。
  • SHUT_WR:关闭连接的写这一半,对于TCP套接字,这称为半关闭(half-close)。当前留在套接字发送缓冲区中的数据将被发送掉,后跟TCP的正常连接终止序列。我们已经说过,不管套接字描述符的引用计数是否等于0,这样的写半部关闭照样执行。进程不能再对这样的套接字调用任何写函数。
  • SHUT_RDWR:连接的读半部和写半部都关闭——这与调用shutdown两次等效:第一次调用指定SHUT_RD,第二次调用指定SHUT_WR。

6.7 再修订版str_cli函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include	"unp.h"

void str_cli(FILE *fp, int sockfd) {
int maxfdp1, stdineof;
fd_set rset;
char buf[MAXLINE];
int n;

stdineof = 0;
FD_ZERO(&rset);
for( ; ; ) {
if (stdineof == 0)
FD_SET(fileno(fp), &rset);
FD_SET(sockfd, &rset);
maxfdp1 = max(fileno(fp), sockfd) + 1;
Select(maxfdp1, &rset, NULL, NULL, NULL);

if(FD_ISSET(sockfd, &rset)) { /* socket is readable */
if((n = Read(sockfd, buf, MAXLINE)) == 0) {
if(stdineof == 1)
return; /* normal termination */
else
err_quit("str_cli: server terminated prematurely");
}
Write(fileno(stdout), buf, n);
}
if(FD_ISSET(fileno(fp), &rset)) { /* input is readable */
// 客户输入了EOF(关闭客户端)
if((n = Read(fileno(fp), buf, MAXLINE)) == 0) {
stdineof = 1;
// 套接字写关闭
Shutdown(sockfd, SHUT_WR); /* send FIN */
FD_CLR(fileno(fp), &rset);
continue;
}
Writen(sockfd, buf, n);
}
}
}

6.8 修订版TCP回射服务器程序

将之前的服务器程序修改为使用select来处理任意个客户的单进程程序,而不是为每个客户派生一个子进程。

image-20230417112134147

服务器有单个监听描述符,我们用一个圆点来表示。

服务器只维护一个读描述符集,如图6-15所示。假设服务器是在前台启动的,那么描述符0、1和2将分别被设置为标准输入、标准输出和标准错误输出。可见监听套接字的第一个可用描述符是3。图6-15还展示了一个名为client的整型数组,它含有每个客户的已连接套接字描述符。该数组的所有元素都被初始化为-1。

image-20230417112223329

描述符集中唯一的非0项是表示监听套接字的项,因此select的第一个参数将为4。

当第一个客户与服务器建立连接时,监听描述符变为可读,我们的服务器于是调用accept。在本例的假设下,由accept返回的新的已连接描述符将是4。图6-16展示了从客户到服务器的连接。

image-20230417112406903

从现在起,我们的服务器必须在其client数组中记住每个新的已连接描述符,并把它加到描述符集中去。图6-17展示了这样更新后的数据结构。

image-20230417112442599

稍后,第二个客户与服务器建立连接,图6-18展示了这种情形。

image-20230417112505738

新的已连接描述符(假设是5)必须被记住,从而给出如图6-19所示的数据结构。

image-20230417112750007我们接着假设第一个客户终止它的连接。该客户的TCP发送一个FIN,使得服务器中的描述符4变为可读。当服务器读这个已连接套接字时,read将返回0。我们于是关闭该套接字并相应地更新数据结构:把client[0]的值置为-1,把描述符集中描述符4的位设置为0,如图6-20所示。注意,maxfd的值没有改变。

image-20230417112855477

代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
/* include fig01 */
#include "unp.h"

int main(int argc, char **argv) {
int i, maxi, maxfd, listenfd, connfd, sockfd;
int nready, client[FD_SETSIZE]; // 记录已连接客户端的sockfd
ssize_t n;
fd_set rset, allset;
char buf[MAXLINE];
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(SERV_PORT);

Bind(listenfd, (SA *) &servaddr, sizeof(servaddr));

Listen(listenfd, LISTENQ);

maxfd = listenfd; /* initialize */
maxi = -1; /* index into client[] array */
for(i = 0; i < FD_SETSIZE; i++)
client[i] = -1; /* -1 indicates available entry */
FD_ZERO(&allset);
FD_SET(listenfd, &allset);

for( ; ; ) {
rset = allset; /* structure assignment */
nready = Select(maxfd+1, &rset, NULL, NULL, NULL);

if(FD_ISSET(listenfd, &rset)) { /* new client connection */
clilen = sizeof(cliaddr);
// 不阻塞而立即返回
connfd = Accept(listenfd, (SA *) &cliaddr, &clilen);
// 在client[]中找到一个可用位置
for(i = 0; i < FD_SETSIZE; i++) {
if (client[i] < 0) {
client[i] = connfd; /* save descriptor */
break;
}
}
if(i == FD_SETSIZE)
err_quit("too many clients");
FD_SET(connfd, &allset); /* add new descriptor to set */
if (connfd > maxfd)
maxfd = connfd; /* for select */
if (i > maxi)
maxi = i; /* max index in client[] array */
if (--nready <= 0)
break; /* no more readable descriptors */
}
// 检查是哪一个客户端的套接字发生了读事件
for(i = 0; i <= maxi; i++) { /* check all clients for data */
if((sockfd = client[i]) < 0)
continue;
if(FD_ISSET(sockfd, &rset)) {
if((n = Read(sockfd, buf, MAXLINE)) == 0) {
/*connection closed by client */
Close(sockfd);
FD_CLR(sockfd, &allset);
client[i] = -1;
} else
Writen(sockfd, buf, n);

if(--nready <= 0)
break; /* no more readable descriptors */
}
}
}
}

本服务器程序版本比之前的版本复杂,不过它避免了为每个客户创建一个新进程的所有开销,因而是一个使用select的精彩例子。

6.9 poll函数

1
2
3
#include <poll.h>
// 就绪描述符个数, 超时0, 出错-1
int poll(struct pollfd *fdarray, unsigned long nfds, int timeout);
  • 第一个参数是指向一个结构数组第一个元素的指针。每个数组元素都是一个pollfd结构,用于指定测试某个给定描述符fd的条件。
1
2
3
4
5
struct pollfd {
int fd; /* 需要监视的文件描述符 */
short events; /* 要监视的事件 */
short revents; /* 该文件描述符发生了的事件 */
};

要测试的条件由events成员指定,函数在相应的revents成员中返回该描述符的状态。(每个描述符都有两个变量,一个为调用值,另一个为返回结果,从而避免使用值-结果参数。回想select函数的中间三个参数都是值-结果参数。)这两个成员中的每一个都由指定某个特定条件的一位或多位构成。图6-23列出了用于指定events标志以及测试revents标志的一些常值。

image-20230417152948856

  • timeout参数指定poll函数返回前等待多长时间。它是一个指定应等待毫秒数的正值。图6-24 给出了它的可能取值。

image-20230417153124879

  • 结构数组中元素的个数是由nfds参数指定。

6.10 再修订版TCP回射服务器程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
#include	"unp.h"
#include <limits.h> /* for OPEN_MAX */

int main(int argc, char **argv) {
int i, maxi, listenfd, connfd, sockfd;
int nready;
ssize_t n;
char buf[MAXLINE];
socklen_t clilen;
struct pollfd client[OPEN_MAX];
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(SERV_PORT);

Bind(listenfd, (SA *) &servaddr, sizeof(servaddr));

Listen(listenfd, LISTENQ);

client[0].fd = listenfd; // 设置感兴趣的套接字描述符
client[0].events = POLLRDNORM; // 普通数据可读
for (i = 1; i < OPEN_MAX; i++)
client[i].fd = -1; /* -1 indicates available entry */
maxi = 0; /* max index into client[] array */

for ( ; ; ) {
// 阻塞等待
nready = Poll(client, maxi+1, INFTIM);

if (client[0].revents & POLLRDNORM) { /* new client connection */
clilen = sizeof(cliaddr);
connfd = Accept(listenfd, (SA *) &cliaddr, &clilen);
for (i = 1; i < OPEN_MAX; i++) {
if (client[i].fd < 0) {
client[i].fd = connfd; /* save descriptor */
break;
}
}
if (i == OPEN_MAX)
err_quit("too many clients");
// 设置事件
client[i].events = POLLRDNORM;
if (i > maxi)
maxi = i; /* max index in client[] array */

if (--nready <= 0)
continue; /* no more readable descriptors */
}
// 非连接的读事件
for (i = 1; i <= maxi; i++) { /* check all clients for data */
if ( (sockfd = client[i].fd) < 0)
continue;
if (client[i].revents & (POLLRDNORM | POLLERR)) {
if ( (n = read(sockfd, buf, MAXLINE)) < 0) {
if (errno == ECONNRESET) {
/*connection reset by client */
Close(sockfd);
client[i].fd = -1;
} else
err_sys("read error");
} else if (n == 0) {
/*connection closed by client */
Close(sockfd);
client[i].fd = -1;
} else
Writen(sockfd, buf, n);

if (--nready <= 0)
break; /* no more readable descriptors */
}
}
}
}

7 套接字选项

7.1 概述

有很多方法来获取和设置影响套接字的选项:

  • getsockopt和setsockopt函数
  • fcntl函数
  • ioctl函数

7.2 getsockopt和setsockopt函数

1
2
3
4
#include <sys/socket.h>
// 成功返回0,失败返回-1
int getsockopt(int sockfd, int level, int optname, void *optval, socklen_t *optlen);
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
  • sockfd必须指向一个打开的套接字描述符
  • level(级别)指定系统中解释选项的代码或为通用套接字代码,或为某个特定于协议的代码(例如IPv4、IPv6、TCP或SCTP)。
  • optval是一个指向某个变量的指针,setsockopt从optval中取得选项待设置的新值,getsockopt则把已获取的选项当前值存放到optval中。optval的大小由最后一个参数指定,它对于setsockopt是一个值参数,对于getsockopt是一个值-结果参数。

下图汇总了可由getsockopt获取或由setsockopt设置的选项。其中的”数据类型”列给出了指针optoal必须指向的每个选项的数据类型。我们用后跟一对花括号的记法来表示一个结构,如linger{}就表示struct linger

image-20230418091939952

image-20230418091958880

image-20230418092009532

套接字选项粗分为两大基本类型:一是启用或禁止某个特性的二元选项(称为标志选项),二是取得并返回我们可以设置或检查的特定值的选项(称为值选项)。标有“标志”的列指出一个选项是否为标志选项。当给这些标志选项调用getsockopt函数时,optval是一个整数。optual 中返回的值为0表示相应选项被禁止,不为0表示相应选项被启用。类似地,setsockopt函数需要一个不为0的optval值来启用选项,一个为0的optval值来禁止选项。如果“标志”列不含有“·”,那么相应选项用于在用户进程与系统之间传递所指定数据类型的值。

7.3 检查选项支持

本节需要检查套接字选项是否支持,并且输出其默认值。

暂略

7.4 套接字状态

对于某些套接字选项,针对套接字的状态,什么时候设置或获取选项有时序上的考虑。我们对受影响的选项论及这一点。

下面的套接字选项是由TCP已连接套接字从监听套接字继承来的:SO_DEBUG、SO_DONTROUTE、SO_KEEPALIVE、SO_LINGER、SO_OOBINLINE、SO_RCVBUF、SO_RCVLOWAT、SO_SNDBUF、SO_SNDLOWAT、TCP_MAXSEG和TCP_NODELAY。这对TCP是很重要的,因为accept一直要到TCP层完成三路握手后才会给服务器返回已连接套接字。如果想在三路握手完成时确保这些套接字选项中的某一个是给已连接套接字设置的,那么我们必须先给监听套接字设置该选项。

7.5 通用套接字选项

通用套接字选项与协议无关。

7.5.1 SO_BROADCAST

暂略

7.5.2 SO_ERROR

当一个套接字上发生错误时,源自Berkeley的内核中的协议模块将该套接字的名为so_error的变量设为标准的Unix Exxx值中的一个,我们称它为该套接字的待处理错误(pending error)。内核能够以下面两种方式之一立即通知进程这个错误。

  1. 如果进程阻塞在对该套接字的select调用上,那么无论是检查可读条件还是可写条件,select均返回并设置其中一个或所有两个条件。
  2. 如果进程使用信号驱动式I/O模型,那就给进程或进程组产生一个SIGIO信号。进程然后可以通过访问SO_ERROR套接字选项获取so_error的值。由getsockopt返回的整数值就是该套接字的待处理错误。so_error随后由内核复位为0。

当进程调用read且没有数据返回时,如果so_error为非0值,那么read返回-1且errno被置为so_error的值。so_error随后被复位为0。如果该套接字上有数据在排队等待读取,那么read返回那些数据而不是返回错误条件。如果在进程调用write时so_error 为非0值,那么write返回-1且errno被设为so_error的值。so_error随后被复位为0。

7.5.3 SO_KEEPALIVE

给一个TCP套接字设置保持存活(keep-alive)选项后,如果2小时内在该套接字的任一方向上都没有数据交换,TCP就自动给对端发送一个保持存活探测分节(keep-alive probe)。这是一个对端必须响应的TCP分节,它会导致以下三种情况之一。

(1)对端以期望的ACK响应。应用进程得不到通知(因为一切正常)。在又经过仍无动静的2小时后,TCP将发出另一个探测分节。

(2)对端以RST响应,它告知本端TCP:对端已崩溃且已重新启动。该套接字的待处理错误被置为ECONNRESET,套接字本身则被关闭。

(3)对端对保持存活探测分节没有任何响应。源自Berkeley的TCP将另外发送8个探测分节,两两相隔75秒,试图得到一个响应。TCP在发出第一个探测分节后11分15秒内若没有得到任何响应则放弃。

如果根本没有对TCP的探测分节的响应,该套接字的待处理错误就被置为ETIMEOUT,套接字本身则被关闭。然而如果该套接字收到一个ICMP错误作为某个探测分节的响应,那就返回相应的错误,套接字本身也被关闭。这种情形下一个常见的ICMP错误是host unreachable(主机不可达),说明对端主机可能并没有崩溃,只是不可达,这种情况下待处理错误被置为EHOSTUNREACH。发生这种情况的原因或者是发生网络故障,或者是对端主机已经崩溃,而最后一跳的路由器也已经检测到它的崩溃。

image-20230418094900186

7.5.4 SO_LINGER

本选项指定close函数对面向连接的协议(例如TCP和SCTP,但不是UDP)如何操作。默认操作是close立即返回,但是如果有数据残留在套接字发送缓冲区中,系统将试着把这些数据发送给对端。

SO_LINGER套接字选项使得我们可以改变这个默认设置。本选项要求在用户进程与内核间传递如下结构,它在头文件<sys/socket.h>中定义:

1
2
3
4
struct linger {
int l_onoff; // 0=off, nonzero=on
int l_linger; // linger time, POSIX sprcifies units as seconds
}

对setsockopt的调用将根据其中两个结构成员的值形成下列3种情形之一:

  1. l_onoff = 0,那么关闭本选项。l_linger的值被忽略,先前讨论的TCP默认设置生效,即close立即返回。
  2. l_onoff = nonzero, l_linger = 0,那么当close某个连接时TCP将中止该连接。这就是说TCP将丢弃保留在套接字发送缓冲区中的任何数据,并发送一个RST给对端,而没有通常的四分组连接终止序列。这么一来避免了TCP的TIME_WAIT状态。
  3. l_onoff = nonzero, l_linger = nonzero,那么套接字关闭时内核将拖延一段时间。这就是说如果在套接字发送缓冲区中仍残留有数据,那么进程将被投入睡眠,直到所有数据都已发送完且均被对方确认或延滞时间到(l_linger)。如果套接字被设置为非阻塞型,那么它将不等待close完成,即使延滞时间为非0也是如此。当使用SO_LINGER选项的这个特性时,应用进程检查close的返回值是非常重要的,因为如果在数据发送完并被确认前延滞时间到的话,close将返回ENOULDBLOCK错误,且套接字发送缓冲区中的任何残留数据都被丢弃。

下图是close的默认操作:立即返回

image-20230418095940350

客户可以设置SO_LINGER套接字选项,指定一个正的延滞时间。这种情况下客户的close要到它的数据和FIN已被服务器主机的TCP确认后才返回,如图7-8所示:

image-20230418100501877

图7-9展示了当给SO_LINGER选项设置偏低的延滞时间值时可能发生的现象:

image-20230418101013501

这里有一个基本原则:设置SO_LINGER套接字选项后,close的成功返回只是告诉我们先前发送的数据(和FIN)已由对端TCP确认,而不能告诉我们对端应用进程是否已读取数据。如果不设置该套接字选项,那么我们连对端TCP是否确认了数据都不知道。

让客户知道服务器已读取其数据的一个方法是改为调用shutdown(并设置它的第二个参数为SHUT_WR)而不是调用close,并等待对端close连接的当地端(服务器端),如图7-10所示。

image-20230418101441222

比较本图与图7-7及图7-8我们看到,当关闭连接的本地端(客户端)时,根据所调用的函数(close或shutdown)以及是否设置了SO_LINGER套接字选项,可在以下3个不同的时机返回。

  1. close立即返回,根本不等待(默认状况,图7-7)。
  2. close一直拖延到接收了对于客户端FIN的ACK才返回(图7-8).
  3. 后跟一个read调用的shutdown一直等到接收了对端的FIN才返回(图7-10).

图7-12汇总了对shutdown的两种可能调用和对close的三种可能调用,以及它们对TCP套接字的影响。

image-20230418101649658

7.5.5 SO_RCVBUF和SO_SNDBUF

每个套接字都有一个发送缓冲区和一个接收缓冲区(位于内核中)。接收缓冲区被TCP、UDP和SCTP用来保存接收到的数据,直到由应用进程来读取。

这两个套接字选项允许我们改变这两个缓冲区的默认大小。对于不同的实现,默认值的大小可以有很大的差别。较早期的源自Berkeley的实现将TCP发送和接收缓冲区的大小均默认为4096字节,而较新的系统使用较大的值,可以是8192~61440字节间的任何值。如果主机支持NFS,那么UDP发送缓冲区的大小经常默认为9000字节左右的一个值,而UDP接收缓冲区的大小则经常默认为40000字节左右的一个值。

当设置TCP套接字接收缓冲区的大小时,函数调用的顺序很重要。这是因为TCP的窗口规模选项是在建立连接时用SYN分节与对端互换得到的。

  • 对于客户,这意味着SO_RCVBUF选项必须在调用connect之前设置
  • 对于服务器,这意味着该选项必须在调用listen之前给监听套接字设置。给已连接套接字设置该选项对于可能存在的窗口规模选项没有任何影响,因为accept直到TCP的三路握手完成才会创建并返回已连接套接字。这就是必须给监听套接字设置本选项的原因。(套接字缓冲区的大小总是由新创建的已连接套接字从监听套接字继承而来)。

7.5.6 SO_RCVLOWAT和SO_SNDLOWAT

每个套接字还有一个接收低水位标记和一个发送低水位标记。它们由select函数使用。这两个套接字选项允许我们修改这两个低水位标记。

接收低水位标记是让select返回“可读”时套接字接收缓冲区中所需的数据量。对于TCP、UDP和SCTP套接字,其默认值为1。发送低水位标记是让select返回“可写”时套接字发送缓冲区中所需的可用空间。对于TCP套接字,其默认值通常为2048。

7.5.7 SO_REUSEADDR

SO_REUSEADDR套接字选项能起到以下4个不同的功用。

  • SO_REUSEADDR允许启动一个监听服务器并捆绑其众所周知端口,即使以前建立的将该端口用作它们的本地端口的连接仍存在。这个条件通常是这样碰到的:

    • a.启动一个监听服务器;

    • b.连接请求到达,派生一个子进程来处理这个客户;

    • c.监听服务器终止,但子进程继续为现有连接上的客户提供服务;

    • d.重启监听服务器。

      默认情况下,当监听服务器在步骤d通过调用socket、bind和listen重新启动时,由于它试图捆绑一个现有连接(即正由早先派生的那个子进程处理着的连接)上的端口,从而bind调用会失败。但是如果该服务器在socket和bind两个调用之间设置了SO_REUSEADDR套接字选项,那么bind将成功。所有TCP服务器都应该指定本套接字选项,以允许服务器在这种情形下被重新启动。

  • SO_REUSEADDR允许在同一端口上启动同一服务器的多个实例(IP),只要每个实例捆绑一个不同的本地IP地址即可。

  • SO_REUSEADDR允许单个进程捆绑同一端口到多个套接字上,只要每次捆绑指定不同的本地IP地址即可。

  • SO_REUSEADDR允许完全重复的捆绑:当一个IP地址和端口已绑定到某个套接字上时,如果传输协议支持,同样的IP地址和端口还可以捆绑到另一个套接字上。一般来说本特性仅支持UDP套接字。

我们以下面的建议来总结对这些套接字选项的讨论:

  1. 在所有TCP服务器程序中,在调用bind之前都设置SO_REUSEADDR套接字选项;
  2. 当编写一个可在同一时刻在同一主机上运行多次的多播应用程序时,设置SO_REUSEADDR 套接字选项,并将所参加多播组的地址作为本地IP地址捆绑。

7.6 fcntl函数

image-20230418103016501

在最后一列指出,POSIX规定fcntl方法是首选的。

fcntl函数提供了与网络编程相关的如下特性:

  • 非阻塞式I/O。通过使用F_SETEL命令设置O_NONBLOCK文件状态标志,我们可以把一个套接字设置为非阻塞型。
  • 信号驱动式I/O。通过使用F_SETFL命令设置O_ASYNC文件状态标志,我们可以把一个套接字设置成一旦其状态发生变化,内核就产生一个SIGIO信号。
  • F_SETOWN命令允许我们指定用于接收SIGIOSIGURG信号的套接字属主(进程ID或进程组ID)。其中SIGIO信号是套接字被设置为信号驱动式I/O型后产生的,SIGURG信号是在新的带外数据到达套接字时产生的。F_GETOWN命令返回套接字的当前属主。

8 基本UDP套接字编程

8.1 概述

图8-1给出了典型的UDP客户/服务器程序的函数调用。客户不与服务器建立连接,而是只管使用sendto函数给服务器发送数据报,其中必须指定目的地(即服务器)的地址作为参数。类似地,服务器不接受来自客户的连接,而是只管调用recvfrom函数,等待来自某个客户的数据到达。recvfrom将与所接收的数据报一道返回客户的协议地址,因此服务器可以把响应发送给正确的客户。

image-20230419105759723

8.2 recvfrom和sendto函数

1
2
3
4
5
6
7
#include <sys/socket.h>
// 读或写的字节数, -1
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *from, socklen_t *addrlen);

ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *to, socklen_t addrlen);

这两个函数类似于标准的read和write函数,不过需要三个额外的参数。

  • flags:标志位,以后讨论,目前设置为0
  • sendto的to参数指向一个含有数据报接收者的协议地址(例如IP地址及端口号)的套接字地址结构,其大小由addrlen参数指定。
  • recvfrom的from参数指向一个将由该函数在返回时填写数据报发送者的协议地址的套接字地址结构,而在该套接字地址结构中填写的字节数则放在addrlen参数所指的整数中返回给调用者。注意,sendto的最后一个参数是一个整数值,而recvfrom的最后一个参数是一个指向整数值的指针(即值-结果参数)。

recvfrom的最后两个参数类似于accept的最后两个参数:返回时其中套接字地址结构的内容告诉我们是谁发送了数据报(UDP情况下)或是谁发起了连接(TCP情况下)。

sendto的最后两个参数类似于connect的最后两个参数:调用时其中套接字地址结构被我们填入数据报将发往(UDP情况下)或与之建立连接(TCP情况下)的协议地址。

这两个函数都把所读写数据的长度作为函数返回值。在recvfrom使用数据报协议的典型用途中,返回值就是所接收数据报中的用户数据量。

如果recvfrom的from参数是一个空指针,那么相应的长度参数(addrlen)也必须是一个空指针,表示我们并不关心数据发送者的协议地址。

recvfrom和sendto都可以用于TCP,尽管通常没有理由这样做。

8.3 服务器程序

8.3.1 main函数

image-20230419110347111

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include	"unp.h"

int main(int argc, char **argv) {
int sockfd;
struct sockaddr_in servaddr, cliaddr;
// SOCK_DGRAM: 报式套接字
sockfd = Socket(AF_INET, SOCK_DGRAM, 0);

bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(SERV_PORT);

Bind(sockfd, (SA *) &servaddr, sizeof(servaddr));
// 执行服务器的处理工作
dg_echo(sockfd, (SA *) &cliaddr, sizeof(cliaddr));
}

8.3.2 dg_echo函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include	"unp.h"

void dg_echo(int sockfd, SA *pcliaddr, socklen_t clilen) {
int n;
socklen_t len;
char mesg[MAXLINE]; // 读缓冲区
// 接收客户的消息
for ( ; ; ) {
len = clilen;
// 客户端地址pcliaddr和地址长度len会被回传填入
n = Recvfrom(sockfd, mesg, MAXLINE, 0, pcliaddr, &len);
// 回射(响应)
Sendto(sockfd, mesg, n, 0, pcliaddr, len);
}
}

该函数提供的是一个迭代服务器(iterative server),而不是像TCP服务器那样可以提供一个并发服务器。其中没有对fork的调用,因此单个服务器进程就得处理所有客户。一般来说,大多数TCP服务器是并发的,而大多数UDP服务器是迭代的。

对于本套接字,UDP层中隐含有排队发生。事实上每个UDP套接字都有一个接收缓冲区,到达该套接字的每个数据报都进入这个套接字接收缓冲区。当进程调用recvfrom时,缓冲区中的下一个数据报以FIFO(先入先出)顺序返回给进程。这样,在进程能够读该套接字中任何已排好队的数据报之前,如果有多个数据报到达该套接字,那么相继到达的数据报仅仅加到该套接字的接收缓冲区中。

image-20230419111326214

服务器主机上有两个已连接套接字,其中每一个都有各自的套接字接收缓冲区。

图8-6展示了两个客户发送数据报到UDP服务器的情形。

image-20230419111408535

其中只有一个服务器进程,它仅有的单个套接字用于接收所有到达的数据报并发回所有的响应。该套接字有一个接收缓冲区用来存放所到达的数据报。

8.4 客户程序

8.4.1 main函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include	"unp.h"

int main(int argc, char **argv) {
int sockfd;
struct sockaddr_in servaddr;

if (argc != 2)
err_quit("usage: udpcli <IPaddress>");
// 对端地址
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(SERV_PORT);
Inet_pton(AF_INET, argv[1], &servaddr.sin_addr);
// 创建报式套接字
sockfd = Socket(AF_INET, SOCK_DGRAM, 0);
// 处理客户任务
dg_cli(stdin, sockfd, (SA *) &servaddr, sizeof(servaddr));

exit(0);
}

8.4.2 dg_cli函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include	"unp.h"
void dg_cli(FILE *fp, int sockfd, const SA *pservaddr, socklen_t servlen) {
int n;
char sendline[MAXLINE], recvline[MAXLINE + 1];

while (Fgets(sendline, MAXLINE, fp) != NULL) {
// 发送数据报
Sendto(sockfd, sendline, strlen(sendline), 0, pservaddr, servlen);
// 不关心应答数据报由谁发送,因此将最后两个参数设置为NULL
n = Recvfrom(sockfd, recvline, MAXLINE, 0, NULL, NULL);

recvline[n] = 0; /* null terminate */
Fputs(recvline, stdout);
}
}

注意,调用recvfrom指定的第五和第六个参数是空指针。这告知内核我们并不关心应答数据报由谁发送。这样做存在一个风险:任何进程不论是在与本客户进程相同的主机上还是在不同的主机上,都可以向本客户的IP地址和端口发送数据报,这些数据报将被客户读入并被认为是服务器的应答。

8.5 数据报丢失

我们的UDP客户/服务器例子是不可靠的。如果一个客户数据报丢失(譬如说,被客户主机与服务器主机之间的某个路由器丢弃),客户将永远阻塞于dg_cli函数中的recvfrom调用,等待一个永远不会到达的服务器应答。类似地,如果客户数据报到达服务器,但是服务器的应答丢失了,客户也将永远阻塞于recvfrom调用。防止这样永久阻塞的一般方法是给客户的recvfrom 调用设置一个超时。

仅仅给recvfrom调用设置超时并不是完整的解决办法。举例来说,如果确实超时了,我们将无从判定超时原因是我们的数据报没有到达服务器,还是服务器的应答没有回到客户。如果客户的请求是”从账户A往账户B转一定数目的钱”而不是我们的简单回射服务器例子,那么请求丢失和应答丢失是极不相同的。

8.6 验证接收到的响应

8.4.2节结尾提到,知道客户临时端口号的任何进程都可往客户发送数据报,而且这些数据报会与正常的服务器应答混杂。我们的解决办法是修改recvfrom调用以返回数据报发送者的IP地址和端口号,保留来自数据报所发往服务器的应答,而忽略任何其他数据报。然而这样做照样存在一些缺陷,例如具有多个IP的服务器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include	"unp.h"

void dg_cli(FILE *fp, int sockfd, const SA *pservaddr, socklen_t servlen) {
int n;
char sendline[MAXLINE], recvline[MAXLINE + 1];
socklen_t len;
struct sockaddr *preply_addr; // 记录原来的服务器套接字地址

preply_addr = Malloc(servlen);

while (Fgets(sendline, MAXLINE, fp) != NULL) {

Sendto(sockfd, sendline, strlen(sendline), 0, pservaddr, servlen);

len = servlen;
n = Recvfrom(sockfd, recvline, MAXLINE, 0, preply_addr, &len);
// 比较应答数据的服务器和原服务器的套接字地址是否相同
if (len != servlen || memcmp(pservaddr, preply_addr, len) != 0) {
printf("reply from %s (ignored)\n", Sock_ntop(preply_addr, len));
continue;
}

recvline[n] = 0; /* null terminate */
Fputs(recvline, stdout);
}
}

8.7 服务器进程未运行

本节讨论,当服务器未运行时,客户发送一个udp数据报会发生什么。

如果我们这么做后在客户上键入一行文本,那么什么也不发生。客户永远阻塞于它的recvfrom调用,等待一个永不出现的服务器应答。

image-20230419113846785

我们从第3行看到客户数据报发出,然而从第4行看到,服务器主机响应的是一个”port unreachable”(端口不可达)ICMP消息。不过这个ICMP错误不返回给客户进程,其原因我们稍后讲述。客户永远阻塞于recvfrom调用

我们称这个ICMP错误为异步错误(asynchronous error)。该错误由sendto引起,但是sendto本身却成功返回。我们知道从UDP输出操作成功返回仅仅表示在接口输出队列中具有存放所形成IP数据报的空间。该ICMP错误直到后来才返回(图8-10所示为4ms之后),这就是称其为异步的原因。

一个基本规则是:对于一个UDP套接字,由它引发的异步错误却并不返回给它,除非它已连接

8.8 UDP的connect函数

暂略

8.9 修订版dg_cli函数

暂略

8.10 UDP缺乏流量控制

暂略

8.11 使用select的TCP/UDP回射服务器

暂略

9 名字与地址转换

暂略

10 IPv4与IPv6的互操作性

暂略

11 守护进程和inetd超级服务器

暂略

12 高级IO函数

12.1 套接字超时

在涉及套接字的I/O操作上设置超时的方法有以下3种。

  1. 调用alarm,它在指定超时期满时产生SIGALRM信号。这个方法涉及信号处理,而信号处理在不同的实现上存在差异,而且可能干扰进程中现有的alarm调用。
  2. 在select中阻塞等待I/O(select有内置的时间限制),以此代替直接阻塞在read或write调用上。
  3. 使用较新的SO_RCVTIMEOSO_SNDTIMEO套接字选项。这个方法的问题在于并非所有实现都支持这两个套接字选项。

上述三个技术都适用于输入和输出操作(例如read、write及其诸如recvfrom、sendto 之类的变体),不过我们依然期待可用于connect的技术,因为TCP内置的connect超时相当长(典型值为75秒钟)。select可用来在connect上设置超时的先决条件是相应套接字处于非阻塞模式,而那两个套接字选项对connect并不适用。我们还指出,前两个技术适用于任何描述符,而第三个技术仅仅使用于套接字描述符。

12.1.1 使用SIGALRM为connect设置超时

下面给出了connect_timeo函数,它以由调用者指定的超时上限调用connect。它的前3个参数用于调用connect,第四个参数是等待的秒数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
/* include connect_timeo */
#include "unp.h"

// 信号处理函数定义
static void connect_alarm(int);

/**
* sockfd: 客户的套接字
* saptr: 服务器套接字地址
* salen: 服务器套接字地址长度
* nsec: 超时时间
* return: 0, -1
*/
int connect_timeo(int sockfd, const SA *saptr, socklen_t salen, int nsec) {
// Sigfunc是返回值为void,参数为int的函数的别名
Sigfunc *sigfunc; // 返回值为void,参数为int的函数指针sigfunc
int n;
// 为SIGALRM注册信号处理函数
// 返回SIGALRM原来的信号处理函数sigfunc
sigfunc = Signal(SIGALRM, connect_alarm);
// nsec秒后内核向调用该函数的进程发送SIGALRM信号
if (alarm(nsec) != 0)
err_msg("connect_timeo: alarm was already set");

if ( (n = connect(sockfd, saptr, salen)) < 0) {
close(sockfd);
if (errno == EINTR) // 被信号中断
errno = ETIMEDOUT;
}
alarm(0); /* turn off the alarm */
Signal(SIGALRM, sigfunc); /* restore previous signal handler */

return(n);
}

// 信号处理函数实现
static void connect_alarm(int signo) {
return; /* just interrupt the connect() */
}
/* end connect_timeo */

void Connect_timeo(int fd, const SA *sa, socklen_t salen, int sec) {
if (connect_timeo(fd, sa, salen, sec) < 0)
err_sys("connect_timeo error");
}

就本例子我们指出两点,第一点是使用本技术总能减少connect的超时期限,但是无法延长内核现有的超时。源自Berkeley的内核中connect的超时通常为75s。在调用我们的函数时,可以指定一个比75小的值(如10),但是如果指定一个比75大的值(如80),那么connect仍将在75s后发生超时。

另一点是我们使用了系统调用(connect)的可中断能力,使得它们能够在内核超时发生之前返回。这一点不成问题的前提是:我们执行的是系统调用,并且能够直接处理由它们返回的EINTR错误。

尽管本例子相当简单,但在多线程化程序中正确使用信号却非常困难。因此我们建议只是在未线程化或单线程化的程序中使用本技术。

12.1.2 使用SIGALRM为recvform设置超时

下列代码修改自dg_cli函数,通过调用alarm使得一旦在5s内收不到任何应答就中断recvfrom

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include	"unp.h"

static void sig_alrm(int);

void dg_cli(FILE *fp, int sockfd, const SA *pservaddr, socklen_t servlen) {
int n;
char sendline[MAXLINE], recvline[MAXLINE + 1];
// SIGALRM注册信号处理函数
Signal(SIGALRM, sig_alrm);

while (Fgets(sendline, MAXLINE, fp) != NULL) {

Sendto(sockfd, sendline, strlen(sendline), 0, pservaddr, servlen);
// 定时5s
alarm(5);
if ( (n = recvfrom(sockfd, recvline, MAXLINE, 0, NULL, NULL)) < 0) {
if (errno == EINTR) // 5s超时后,被SIGALRM中断; 或者被其他的信号中断
fprintf(stderr, "socket timeout\n");
else
err_sys("recvfrom error");
} else {
// 关闭定时
alarm(0);
recvline[n] = 0; /* null terminate */
Fputs(recvline, stdout);
}
}
}

static void sig_alrm(int signo) {
return; /* just interrupt the recvfrom() */
}

12.1.3 使用select为recvfrom设置超时

示例了设置超时的第二个技术(使用select)。这个名为readable_timeo的函数等待一个描述符最多在指定的秒数内变为可读。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/* include readable_timeo */
#include "unp.h"

int readable_timeo(int fd, int sec) {
fd_set rset;
struct timeval tv;
// 先初始化再设置
FD_ZERO(&rset);
FD_SET(fd, &rset);
// s and ms
tv.tv_sec = sec;
tv.tv_usec = 0;
// 等待可读,超时返回-1,否则返回正值
return(select(fd+1, &rset, NULL, NULL, &tv));
/* > 0 if descriptor is readable */
}
/* end readable_timeo */

int Readable_timeo(int fd, int sec) {
int n;
if ( (n = readable_timeo(fd, sec)) < 0)
err_sys("readable_timeo error");
return(n);
}

dg_cli中使用这个函数,这个新版本只是在readable_timeo返回一个正值时才调用recvfrom。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include	"unp.h"

void dg_cli(FILE *fp, int sockfd, const SA *pservaddr, socklen_t servlen) {
int n;
char sendline[MAXLINE], recvline[MAXLINE + 1];

while (Fgets(sendline, MAXLINE, fp) != NULL) {

Sendto(sockfd, sendline, strlen(sendline), 0, pservaddr, servlen);
// select等待5s,如果sockfd在5s内可读,则返回正值,执行else
// 否则返回-1,执行if
if (Readable_timeo(sockfd, 5) == 0) {
fprintf(stderr, "socket timeout\n");
} else {
// 由于sockfd可读,因此Recvfrom可以立即返回
n = Recvfrom(sockfd, recvline, MAXLINE, 0, NULL, NULL);
recvline[n] = 0; /* null terminate */
Fputs(recvline, stdout);
}
}
}

12.1.4 使用SO_RCVTIMEO为recvfrom设置超时

最后一个例子展示SO_RCVTIMEO套接字选项如何设置超时。本选项一旦设置到某个描述符(包括指定超时值),其超时设置将应用于该描述符上的所有读操作。本方法的优势就体现在一次性设置选项上,而前两个方法总是要求我们在欲设置时间限制的每个操作发生之前做些工作。本套接字选项仅仅应用于读操作,类似的SO_SNDTIMEO选项则仅仅应用于写操作,两者都不能用于为connect设置超时。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include	"unp.h"

void dg_cli(FILE *fp, int sockfd, const SA *pservaddr, socklen_t servlen) {
int n;
char sendline[MAXLINE], recvline[MAXLINE + 1];
struct timeval tv;

tv.tv_sec = 5;
tv.tv_usec = 0;
// 为sockfd设置套接字选项SO_RCVTIMEO
Setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));

// 下面的操作和原始版本相同
while (Fgets(sendline, MAXLINE, fp) != NULL) {

Sendto(sockfd, sendline, strlen(sendline), 0, pservaddr, servlen);

n = recvfrom(sockfd, recvline, MAXLINE, 0, NULL, NULL);
if (n < 0) {
if (errno == EWOULDBLOCK) { // 这里返回EWOULDBLOCK错误
fprintf(stderr, "socket timeout\n");
continue;
} else
err_sys("recvfrom error");
}

recvline[n] = 0; /* null terminate */
Fputs(recvline, stdout);
}
}

12.2 recv和send函数

这两个函数类似标准的read和write函数,不过需要一个额外的参数。

1
2
3
4
5
#include <sys/socket.h>
// 读或写的字节数, -1
ssize_t recv(int sockfd, void *buf, size_t len, int flags);

ssize_t send(int sockfd, const void *buf, size_t len, int flags);

flags参数可为0,或者下面的参数:

image-20230420102917559

12.3 readv和writev函数

这两个函数类似read和write,不过readv和writev允许单个系统调用读入到或写出自一个或多个缓冲区。这些操作分别称为分散读(scatter read)和集中写(gather write),因为来自读操作的输入数据被分散到多个应用缓冲区中,而来自多个应用缓冲区的输出数据则被集中提供给单个写操作。

1
2
3
4
5
#include <sys/uio.h>
// 读或写的字节数, -1
ssize_t readv(int fieldes, const struct iovec *iov, int iovcnt);

ssize_t writev(int fieldes, const struct iovec *iov, int iovcnt);

这两个函数的第二个参数都是指向某个iovec结构数组的一个指针,其中iovec结构在头文件<sys/uio.h>中定义:

1
2
3
4
struct iovec {
void *iov_base; // starting address of buffer
size_t iov_len; // size of buffer
};

readv和wcitev这两个函数可用于任何描述符,而不仅限于套接字。另外writev是一个原子操作,意味着对于一个基于记录的协议(例如UDP)而言,一次writev调用只产生单个UDP 数据报。

12.4 recvmsg和sendmsg函数

这两个函数是最通用的I/O函数。实际上我们可以把所有read、readv、recv和recvfrom 调用替换成recvmsg调用。类似地,各种输出函数调用也可以替换成sendmsg调用。

1
2
3
4
5
#include <sys/socket.h>
// 读或写的字节数, -1
ssize_t recvmsg(int sockfd, struct msghdr *msg, int flags);

ssize_t sendmsg(int sockfd, struct msghdr *msg, int flags);

这两个函数把大部分参数封装到一个msghdr结构中:

1
2
3
4
5
6
7
8
9
struct msghdr {
void *msg_name; // 套接字地址
socklen_t msg_namelen; // 套接字地址长度
struct iovec *msg_iov; // 分散读/集中写的结构体数组
int msg_iovlen; // 数组长度
void *msg_control; // 辅助数据
socklen_t msg_controllen; // 辅助数据长度
int msg_flags; // flags returned by recvmsg()
};

msg_namemsg_namelen这两个成员用于套接字未连接的场合(譬如未连接UDP套接字)。它们类似recvfrom和sendto的第五个和第六个参数:msg_name指向一个套接字地址结构,调用者在其中存放接收者(对于sendmsg调用)或发送者(对于recvmsg调用)的协议地址。如果无需指明协议地址(例如对于TCP套接字或已连接UDP套接字),msg_name应置为空指针。msg_namelen对于sendmsg是一个值参数,对于recvmsg却是一个值-结果参数。

msg_iovmsg_iovlen这两个成员指定输入或输出缓冲区数组(即iovec结构数组),类似readv或writev的第二个和第三个参数。

msg_controlmsg_controllen这两个成员指定可选的辅助数据的位置和大小。msg_controllen对于recvmsg是一个值-结果参数。

示例

图14-8展示了一个msghdr结构以及它指向的各种信息。图中假设进程即将对一个UDP套接字调用recvmsg。

image-20230420105158983

图中给协议地址分配了16个字节,给辅助数据分配了20个字节。为缓冲数据初始化了一个由3个iovec结构构成的数组:第一个指定一个100字节的缓冲区,第二个指定一个60字节的缓冲区,第三个指定一个80字节的缓冲区。我们还假设已为这个套接字设置了IP_RECVDSTADDR套接字选项,以接收所读取UDP数据报的目的IP地址。

我们接着假设从198.6.38.100端口2000到达一个170字节的UDP数据报,它的目的地是我们的UDP套接字,目的IP地址为206.168.112.96。图14-9展示了recvmsg返回时msghdr结构中的所有信息。

image-20230420105322183

图中被recvmsg修改过的字段标上了阴影。从图14-8到图14-9的变动包括以下几点。

  • 由msg_name成员指向的缓冲区被填以一个网际网套接字地址结构,其中有所收到数据报的源IP地址和源UDP端口号。
  • msg_namelen成员(一个值-结果参数)被更新为存放在msg_name所指缓冲区中的数据量。本成员并无变化,因为recvmsq调用前和返回后其值均为16。
  • 所收取数据报的前100字节数据存放在第一个缓冲区,中60字节数据存放在第二个缓冲区,后10字节数据存放在第三个缓冲区。最后那个缓冲区的后70字节没有改动。recvmsg 函数的返回值(即170)就是该数据报的大小。
  • 由msg_control成员指向的缓冲区被填以一个cmsghdr结构。该cmsghdr结构中,cmsg_len成员值为16,cmsg_level成员值为IPPROTO_IP,cmsg_type成员值为IP_RECVDSTADDR,随后4个字节存放所收到UDP数据报的目的IP地址。这个20字节缓冲区的后4个字节没有改动。
  • msg_controllen成员被更新为所存放辅助数据的实际数据量。本成员也是一个值-结果参数,recvmsg返回时其结果为16。
  • msg_flags成员同样被recvmsg更新,不过没有标志返回给进程。

IO函数对比

image-20230420105736028

12.5 辅助数据

暂略

12.6 套接字和标准IO

到目前为止的所有例子中,我们一直使用也称为Unix I/O——包括read、write这两个函数及它们的变体(recv、send等等)——的函数执行I/O。这些函数围绕描述符(descriptor)工作,通常作为Unix内核中的系统调用实现(文件IO、低级IO、系统调用IO)。

执行I/O的另一个方法是使用标准I/O函数库(高级IO)(standard I/O library)。这个函数库由ANSIC标准规范,意在便于移植到支持ANSI C的非Unix系统上。标准I/O函数库处理我们直接使用Unix I/O 函数时必须考虑的一些细节,譬如自动缓冲输入流和输出流

标准IO可以用于套接字,但要注意:

  • 通过调用fdopen,可以从任何一个描述符创建出一个标准I/O流。类似地,通过调用fileno,可以获取一个给定标准I/O流对应的描述符。
  • TCP和UDP套接字是全双工的。标准I/O流也可以是全双工的:只要以r+类型打开流即可,r+意味着读写。然而在这样的流上,我们必须在调用一个输出函数之后插入一个fflush、fseek、fsetpos或rewind调用才能接着调用一个输入函数。类似地,调用一个输入函数后也必须插入一个fseek、fsetpos或rewind调用才能调用一个输出函数,除非输入函数遇到一个EOF。fseek、fsetpos和rewind这3个函数的问题是它们都调用lseek,而lseek用在套接字上只会失败。
  • 解决上述读写问题的最简单方法是为一个给定套接字打开两个标准I/O流:一个用于读,一个用于写。

示例:使用标准IO的str_echo函数

1
2
3
4
5
6
7
8
9
10
11
12
13
#include	"unp.h"

void str_echo(int sockfd) {
char line[MAXLINE]; // 缓冲区 4096bytes
FILE *fpin, *fpout; // 输入流,输出流

fpin = Fdopen(sockfd, "r"); // Fdopen: 从描述符中创建一个标准IO流
fpout = Fdopen(sockfd, "w");

while (Fgets(line, MAXLINE, fpin) != NULL)
// 回射
Fputs(line, fpout);
}

如果以这个版本的str_echo运行我们的服务器,然后运行其客户,我们得到以下结果:

1
2
3
4
5
6
7
8
hpux % topcli02 206.168.112.96
hello, world 键入本行,但无回射输出
and hi 再键入本行,仍无回射输出
hello?? 再键入本行,仍无回射输出
^D 键入EOF字符
hello, world 至此才输出那三个回射行
and hi
hello??

原因:缓冲问题。

标准IO执行三种缓冲:

  • 完全缓冲:只有出现下面的情况之一,才会发生IO(低级IO)
    • 缓冲区满(通常为8192字节)
    • 显式调用fflush
    • 进程exit退出
  • 行缓冲
    • 碰到一个换行符
    • 显式调用fflush
    • 进程exit退出
  • 无缓冲
    • 每次调用标准IO输出函数时立即发生IO

对于大多数Unix实现,使用下列规则:

  • 标准错误输出总是不缓冲。
  • 终端的标准输入和标准输出为行缓冲
  • 所有其他I/O流都是完全缓冲,除非它们指代终端设备(这种情况下它们行缓冲)。套接字非终端设备,因此采用完全缓冲。

分析:

  • 我们键入第一行输入文本,它被发送到服务器。
  • 服务器用fgets读入本行,再用fputs回射本行(hello, world)。
  • 服务器的标准I/O流被标准I/O函数库完全缓冲。这意味着该函数库把回射行(hello, world)复制到输出流的标准I/O缓冲区(应用进程的内存),但是不把该缓冲区中的内容写到描述符,因为该缓冲区未满。
  • 我们键入第二行输入文本,它被发送到服务器。
  • 服务器用fgets读入本行,再用fputs回射本行。
  • 服务器的标准I/O函数库再次把回射行复制到输出流的标准I/O缓冲区,但是不把该缓冲区中的内容写到描述符,因为该缓冲区仍未满。
  • 同样的情形发生在我们键入的第三行文本上。
  • 我们键入EOF字符,致使我们的str_cli函数调用shutdown,从而发送一个FIN到服务器。
  • 服务器TCP收取这个FIN,它被fgets读入,致使fgets返回一个空指针
  • str_echo函数返回到服务器的main函数,子进程通过调用exit终止。
  • C库函数exit调用标准I/O清理函数。之前由我们的fputs调用填入输出缓冲区中的未满内容现被输出。
  • 服务器子进程终止,致使它的已连接套接字被关闭,从而发送一个FIN到客户,完成TCP的四分组终止序列。
  • 我们的str_cli函数收取并输出由服务器回射的三行文本。str_cli接着在其套接字上收到一个EOF,客户于是终止。

13 Unix域协议

13.1 概述

Unix域协议并不是一个实际的协议族,而是在单个主机上执行客户/服务器通信的一种方法,所用API就是在不同主机上执行客户/服务器通信所用的API(套接字API)。

进程间通信(IPC)实际上就是单个主机上的客户/服务器通信,Unix域协议因此可视为IPC方法之一。

Unix域提供两类套接字:字节流套接字(类似TCP)和数据报套接字(类似UDP)。

Unix域中用于标识客户和服务器的协议地址是普通文件系统中的路径名。我们知道IPv4协议地址由一个32位地址和一个16位端口号构成,IPv6协议地址则由一个128位地址和一个16位端口号构成。这些路径名不是普通的Unix文件:除非把它们和Unix域套接字关联起来,否则无法读写这些文件。

13.2 Unix域套接字地址结构

在头文件<sys/un.h>中定义:

1
2
3
4
struct sockaddr_un {
sa_family_t sun_family; // AF_LOCAL
cahr sun_path[104]; // null-terminated pathname
};

存放在sun_path数组中的路径名必须以空字符结尾。实现提供的SUN_LEN宏以一个指向sockaddr_un结构的指针为参数并返回该结构的长度,其中包括路径名中非空字节数。未指定地址通过以空字符串作为路径名指示,也就是一个sun_path[0]值为0的地址结构。它等价于IPv4的INADDR_ANY常值以及IPv6的IN6ADDR_ANY_INIT常值。

POSIX把Unix域协议重新命名为本地IPC,以消除它对于Unix操作系统的依赖。历史性常值AF_UNIX变为AF_LOCAL。尽管如此,我们依然使用”Unix域”这个称谓,因为这已成为它约定俗成的名字,与支撑它的操作系统无关。另外,尽管POSIX努力使它独立于操作系统,它的套接字地址结构仍然保留_un后缀。

bind使用

程序创建一个Unix域套接字,往其上bind一个路径名,再调用getsockname输出这个绑定的路径名。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include	"unp.h"

int main(int argc, char **argv) {
int sockfd;
socklen_t len;
struct sockaddr_un addr1, addr2;

if (argc != 2)
err_quit("usage: unixbind <pathname>");
// Unix域,流式套接字
sockfd = Socket(AF_LOCAL, SOCK_STREAM, 0);

unlink(argv[1]); /* OK if this fails */

bzero(&addr1, sizeof(addr1));
addr1.sun_family = AF_LOCAL;
// 将argv[1](pathname)复制到addr1.sun_path中
strncpy(addr1.sun_path, argv[1], sizeof(addr1.sun_path)-1);
// 绑定,使用SUN_LEN宏来获取addr1的长度
Bind(sockfd, (SA *) &addr1, SUN_LEN(&addr1));

len = sizeof(addr2);
// addr2为值-结果参数
Getsockname(sockfd, (SA *) &addr2, &len);
printf("bound name = %s, returned len = %d\n", addr2.sun_path, len);

exit(0);
}

程序解释:

程序中用到了unlink函数,函数如下:

1
2
3
#include<unistd.h>
// 0, -1
int unlink(const char* pathname);

功能详解:unlink从文件系统中中删除一个名字:

  • 若这个名字是指向这个文件的最后一个链接,并且没有进程处于打开这个文件的状态,则删除这个文件,释放这个文件占用的空间。
  • 如果这个名字是指向这个文件的最后一个链接,但有某个进程处于打开这个文件的状态,则暂时不删除这个文件,要等到打开这个文件的进程关闭这个文件的文件描述符后才删除这个文件。
  • 如果这个名字指向一个符号链接,则删除这个符号链接。
  • 如果这个名字指向一个socket,管道或者一个设备,则这个socket、管道和设备的名字被删除,当时打开这些socket、管道和设备的进程仍然可以使用它们。
  • 如果不存在,则返回-1

调用bind捆绑到套接字上的路径名就是命令行参数。如果文件系统中已存在该路径名,bind将会失败。为此先调用unlink删除这个路径名,以防它已经存在。如果它不存在,unlink将返回一个可以忽略的错误。

运行结果:

1
2
3
4
[root@HongyiZeng unixdomain]# ./unixbind /tmp/moose
bound name = /tmp/moose, returned len = 13
[root@HongyiZeng unixdomain]# ll /tmp/moose
srwxr-xr-x 1 root root 0 Apr 18 11:18 /tmp/moose

可以看出/tmp/moose是socket类型的文件。

13.3 socketpair函数

socketpair函数创建两个随后连接起来的套接字。本函数仅适用于Unix域套接字。

1
2
3
#include <sys/socket.h>
// 0, -1
int socketpair(int family, int type, int protocol, int sockfd[2]);
  • family参数必须为AF_LOCAL
  • protocol参数必须为0
  • type参数既可以是SOCK_STREAM,也可以是SOCK_DGRAM
  • 新创建的两个套接字描述符作为sockfd[0]sockfd[1].

指定type参数为SOCK_STREAM调用socketpair得到的结果称为流管道(stream pipe)。它与调用pipe创建的普通Unix管道类似,差别在于流管道是全双工的,即两个描述符都是既可读又可写。

13.4 套接字函数

当用于Unix域套接字时,套接字函数中存在一些差异和限制:

  • 由bind创建的路径名默认访问权限应为0777(属主用户、组用户和其他用户都可读、可写并可执行),并按照当前umask值进行修正。
  • 与Unix域套接字关联的路径名应该是一个绝对路径名,而不是一个相对路径名。
  • 在connect调用中指定的路径名必须是一个当前绑定在某个打开的Unix域套接字上的路径名,而且它们的套接字类型(字节流或数据报)也必须一致。
  • 调用connect连接一个Unix域套接字涉及的权限测试等同于调用open以只写方式访问相应的路径名。
  • Unix域字节流套接字类似TCP套接字:它们都为进程提供一个无记录边界的字节流接口。
  • 如果对于某个Unix域字节流套接字的connect调用发现这个监听套接字的队列已满,调用就立即返回一个ECONNREFUSED错误。这一点不同于TCP:如果TCP监听套接字的队列已满,TCP监听端就忽略新到达的SYN,而TCP连接发起端将数次发送SYN进行重试。
  • Unix域数据报套接字类似于UDP套接字:它们都提供一个保留记录边界的不可靠的数据报服务。
  • 在一个未绑定的Unix域套接字上发送数据报不会自动给这个套接字捆绑一个路径名,这一点不同于UDP套接字:在一个未绑定的UDP套接字上发送UDP数据报导致给这个套接字捆绑一个临时端口。这一点意味着除非数据报发送端已经捆绑一个路径名到它的套接字,否则数据报接收端无法发回应答数据报。类似地,对于某个Unix域数据报套接字的connect调用不会给本套接字捆绑一个路径名,这一点不同于TCP和UDP。

13.5 字节流客户/服务器程序

现在把第5章中的TCP回射客户/服务器程序重新编写成使用Unix域套接字。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include	"unp.h"

int main(int argc, char **argv) {
int listenfd, connfd;
pid_t childpid;
socklen_t clilen;
// 套接字地址结构为Unix域的套接字
struct sockaddr_un cliaddr, servaddr;
void sig_chld(int);

listenfd = Socket(AF_LOCAL, SOCK_STREAM, 0);

unlink(UNIXSTR_PATH); // "/tmp/unix.str"
// 设置本机(父进程)地址
bzero(&servaddr, sizeof(servaddr));
servaddr.sun_family = AF_LOCAL;
strcpy(servaddr.sun_path, UNIXSTR_PATH);
// 绑定
Bind(listenfd, (SA *) &servaddr, sizeof(servaddr));
// 监听
Listen(listenfd, LISTENQ);
// 为SIGCHLD注册处理函数sig_chld
Signal(SIGCHLD, sig_chld);

for ( ; ; ) {
clilen = sizeof(cliaddr);
if ( (connfd = accept(listenfd, (SA *) &cliaddr, &clilen)) < 0) {
if (errno == EINTR) // 被信号打断
continue; /* back to for() */
else
err_sys("accept error");
}

if ( (childpid = Fork()) == 0) { /* child process */
Close(listenfd); /* close listening socket */
str_echo(connfd); /* process request */
exit(0);
}
Close(connfd); /* parent closes connected socket */
}
}

客户程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include	"unp.h"

int main(int argc, char **argv) {
int sockfd;
// Unix域套接字地址
struct sockaddr_un servaddr;

sockfd = Socket(AF_LOCAL, SOCK_STREAM, 0);

bzero(&servaddr, sizeof(servaddr));
servaddr.sun_family = AF_LOCAL;
strcpy(servaddr.sun_path, UNIXSTR_PATH);

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

str_cli(stdin, sockfd); /* do it all */

exit(0);
}

13.6 数据报客户/服务器程序

服务器程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include	"unp.h"

int main(int argc, char **argv) {
int sockfd;
// Unix域套接字地址
struct sockaddr_un servaddr, cliaddr;

sockfd = Socket(AF_LOCAL, SOCK_DGRAM, 0);

unlink(UNIXDG_PATH); // "/tmp/unix.dg"
bzero(&servaddr, sizeof(servaddr));
servaddr.sun_family = AF_LOCAL;
strcpy(servaddr.sun_path, UNIXDG_PATH);

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

dg_echo(sockfd, (SA *) &cliaddr, sizeof(cliaddr));
}

客户程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include	"unp.h"

int main(int argc, char **argv) {
int sockfd;
struct sockaddr_un cliaddr, servaddr;

sockfd = Socket(AF_LOCAL, SOCK_DGRAM, 0);

bzero(&cliaddr, sizeof(cliaddr)); /* bind an address for us */
cliaddr.sun_family = AF_LOCAL;
strcpy(cliaddr.sun_path, tmpnam(NULL));
// 注意,需要显式绑定
Bind(sockfd, (SA *) &cliaddr, sizeof(cliaddr));

bzero(&servaddr, sizeof(servaddr)); /* fill in server's address */
servaddr.sun_family = AF_LOCAL;
strcpy(servaddr.sun_path, UNIXDG_PATH);
// 和之前的dg_cli一样
dg_cli(stdin, sockfd, (SA *) &servaddr, sizeof(servaddr));

exit(0);
}

13.7 描述符传递

当考虑从一个进程到另一个进程传递打开的描述符时,我们通常会想到:

  • fork调用返回之后,子进程共享父进程的所有打开的描述符;
  • exec调用执行之后,所有描述符通常保持打开状态不变。

第一个例子中,进程先打开一个描述符,再调用fork,然后父进程关闭这个描述符,子进程则处理这个描述符。这样一个打开的描述符就从父进程传递到子进程。然而我们也可能想让子进程打开一个描述符并把它传递给父进程。

当前的Unix系统提供了用于从一个进程向任一其他进程传递任一打开的描述符的方法。也就是说,这两个讲程之间无需存在亲缘关系,譬如父子进程关系。这种技术要求首先在这两个进程之间创建一个Unix域套接字,然后使用sendmsg跨这个套接字发送一个特殊消息。这个消息由内核来专门处理,会把打开的描述符从发送进程传递到接收进程。

在两个进程之间传递描述符涉及的步骤如下。

  • 创建一个字节流的或数据报的Unix域套接字。

    如果目标是fork一个子进程,让子进程打开待传递的描述符,再把它传递回父进程,那么父进程可以预先调用socketpair创建一个可用于在父子进程之间交换描述符的流管道。

    如果进程之间没有亲缘关系,那么服务器进程必须创建一个Unix域字节流套接字,bind一个路径名到该套接字,以允许客户进程connect到该套接字。然后客户可以向服务器发送一个打开某个描述符的请求,服务器再把该描述符通过Unix域套接字传递回客户。客户和服务器之间也可以使用Unix域数据报套接字,不过这么做没什么好处,而且数据报还存在被丢弃的可能性。在本节的例子中,客户和服务器之间使用字节流套接字。

  • 发送进程通过调用返回描述符的任一Unix函数打开一个描述符,这些函数的例子有open、pipe、nkfifo、socket和accept。可以在进程之间传递的描述符不限类型,这就是我们称这种技术为“描述符传递”而不是“文件描述符传递”的原因。

  • 发送进程创建一个msghdr结构,其中含有待传递的描述符。POSIX规定描述符作为辅助数据(msghdr结构的msg_control成员,见14.6节)发送,不过较老的实现使用msg_accrights成员。发送进程调用sendmsg跨来自步骤1的Unix域套接字发送该描述符。至此我们说这个描述符”在飞行中(in flight)”。即使发送进程在调用sendmsq之后但在接收进程调用recvmsg(见下一步骤)之前关闭了该描述符,对于接收进程它仍然保持打开状态。发送一个描述符会使该描述符的引用计数加1。

  • 接收进程调用recvmsg在来自步骤1的Unix域套接字上接收这个描述符。这个描述符在接收进程中的描述符号不同于它在发送进程中的描述符号是正常的。传递一个描述符并不是传递一个描述符号,而是涉及在接收进程中创建一个新的描述符,而这个新描述符和发送进程中飞行前的那个描述符指向内核中相同的文件表项。

示例

我们现在给出一个描述符传递的例子。这是一个名为mycat的程序,它通过命令行参数取得一个路径名,打开这个文件,再把文件的内容复制到标准输出。该程序调用我们名为my_open的函数,而不是调用普通的Unix open函数。my_open创建一个流管道,并调用fork和exec启动执行另一个程序,期待输出的文件由这个程序打开。该程序随后必须把打开的描述符通过流管道传递回父进程。

通过调用socketpair创建一个流管道后的mycat进程。我们以[0][1]标示socketpair返回的两个描述符。

image-20230419105453026

暂略

13.8 小结

Unix域套接字是客户和服务器在同一个主机上的IPC方法之一。与IPC其他方法相比,Unix 域套接字的优势体现在其API几乎等同于网络客户/服务器使用的API。与客户和服务器在同一个主机上的TCP相比,Unix域字节流套接字的优势体现在性能的增长上。

我们把自己的TCP和UDP回射客户和服务器程序修改成了使用Unix域协议的版本,其中唯一的主要差别是:必须bind一个路径名到UDP套接字(对应Unix域数据报套接字)的客户,以使UDP服务器有发送应答的目的地。

同一个主机上客户和服务器之间的描述符传递是一个非常有用的技术,它通过Unix域套接字发生。

14 非阻塞式IO

14.1 概述

套接字的默认状态是阻塞的。这就意味着当发出一个不能立即完成的套接字调用时,其进程将被投入睡眠,等待相应操作完成。可能阻塞的套接字调用可分为以下四类:

  • 输入操作,包括read、readv、recv、recvfrom和recvmsg共5个函数。

如果某个进程对一个阻塞的TCP套接字(默认设置)调用这些输入函数之一,而且该套接字的接收缓冲区(内核中)中没有数据可读,该进程将被投入睡眠,直到有一些数据到达。

既然TCP是字节流协议,该进程的唤醒就是只要有一些数据到达,这些数据既可能是单个字节,也可以是一个完整的TCP分节中的数据。

既然UDP是数据报协议,如果一个阻塞的UDP套接字的接收缓冲区为空,对它调用输入函数的进程将被投入睡眠,直到有UDP数据报到达。

对于非阻塞的套接字,如果输入操作不能被满足(对于TCP套接字即至少有一个字节的数据可读,对于UDP套接字即有一个完整的数据报可读),相应调用将立即返回一个EWOULDBLOCK错误。

  • 输出操作,包括write、writev、send、sendto和sendmsg共5个函数。

对于一个TCP套接字,内核将从应用进程的缓冲区到该套接字的发送缓冲区复制数据。对于阻塞的套接字,如果其发送缓冲区中没有空间,进程将被投入睡眠,直到有空间为止。

对于一个非阻塞的TCP套接字,如果其发送缓冲区中根本没有空间,输出函数调用将立即返回一个EWOULDBLOCK错误。如果其发送缓冲区中有一些空间,返回值将是内核能够复制到该缓冲区中的字节数。这个字节数也称为不足计数(short count)。

UDP套接字不存在真正的发送缓冲区。内核只是复制应用进程数据并把它沿协议栈向下传送,渐次冠以UDP首部和IP首部。因此对一个阻塞的UDP套接字(默认设置),输出函数调用将不会因与TCP套接字一样的原因而阻塞,不过有可能会因其他的原因而阻塞。

  • 接受外来连接,即accept函数。

如果对一个阻塞的套接字调用accept函数,并且尚无新的连接到达,调用进程将被投入睡眠。

如果对一个非阻塞的套接字调用accept函数,并且尚无新的连接到达,accept调用将立即返回一个EWOULDBLOCK错误。

  • 发起外出连接,即用于TCP的connect函数。

TCP连接的建立涉及一个三路握手过程,而且connect函数一直要等到客户收到对于自己的SYN的ACK为止才返回。这意味着TCP的每个connect总会阻塞其调用进程至少一个到服务器的RTT时间。

如果对一个非阻塞的TCP套接字调用connect,并且连接不能立即建立,那么连接的建立能照样发起(譬如送出TCP三路握手的第一个分组),不过会返回一个EINPROGRESS错误。注意这个错误不同于上述三个情形中返回的错误。另请注意有些连接可以立即建立,通常发生在服务器和客户处于同一个主机的情况下。因此即使对于一个非阻塞的connect,我们也得预备connect成功返回的情况发生。

EWOULDBLOCKEAGAIN的区别

两个错误码没有区别。

按照传统,对于不能被满足的非阻塞式I/O操作,System V会返回EAGAIN错误,而源自Berkeley的实现则返回EMOULDBLOCK错误,顾及历史原因,POSIX规范声称这种情况下这两个错误码都可以返回,幸运的是,大多数当前的系统把这两个错误码定义成相同的值(检查一下你自己的系统中的<sys/errno.h>头文件),因此具体使用哪一个并无多大关系,我们在本书中使用EWOULDBLOCK

14.2 非阻塞读和写

14.2.1 修订版str_cli函数

6.4节使用的select的版本仍使用阻塞式I/O。举例来说,如果在标准输入有一行文本可读,我们就调用read读入它,再调用writen把它发送给服务器。然而如果套接字发送缓冲区已满,writen调用将会阻塞。在进程阻塞于writen调用期间,可能有来自套接字接收缓冲区的数据可供读取。类似的,如果从套接字中有一行输入文本可读,那么一旦标准输出比网络还要慢,进程照样可能阻塞于后续的write调用。

本节的目标是开发这个函数的一个使用非阻塞式I/O的版本。这样可以防止进程在可做任何有效工作期间发生阻塞。在6.2.2节提到,非阻塞IO一般采用轮询的方法来检测条件是否达到,为了避免这种情况,我们采用IO多路复用的方式。

我们维护着两个缓冲区:to(进程发送缓冲区)容纳从标准输入到服务器(套接字发送缓冲区)去的数据,fr(进程接收缓冲区)容纳自服务器(套接字接收缓冲区)到标准输出来的数据。

图16-1展示了to缓冲区的组织和指向该缓冲区中的指针。

image-20230422101423156

  • tooptr:指向从标准输入读入的数据可以存放的下一个字节
  • toiptr-tooptr个字节需要写到套接字
  • 可从标准输入读入的字节数为&to[MAXLINE] - toiptr
  • tooptr移动到toiptr时,两个指针一起恢复到缓冲区的开始处

图16-2展示了fr缓冲区相应的组织:(注:上面的标准输入应该为套接字)

image-20230422102330427

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
/* include nonb1 */
#include "unp.h"

void str_cli(FILE *fp, int sockfd) {
// stdineof 0:尚未读到EOF(标准输入未结束) 1:读到EOF(标准输入结束)
int maxfdp1, val, stdineof;
ssize_t n, nwritten;
fd_set rset, wset;
char to[MAXLINE], fr[MAXLINE];
char *toiptr, *tooptr, *friptr, *froptr;
// 设置所有描述符为非阻塞(非阻塞IO的前提)
val = Fcntl(sockfd, F_GETFL, 0);
Fcntl(sockfd, F_SETFL, val | O_NONBLOCK);

val = Fcntl(STDIN_FILENO, F_GETFL, 0);
Fcntl(STDIN_FILENO, F_SETFL, val | O_NONBLOCK);

val = Fcntl(STDOUT_FILENO, F_GETFL, 0);
Fcntl(STDOUT_FILENO, F_SETFL, val | O_NONBLOCK);

toiptr = tooptr = to; /* initialize buffer pointers */
friptr = froptr = fr;
stdineof = 0;
// 最大描述符
maxfdp1 = max(max(STDIN_FILENO, STDOUT_FILENO), sockfd) + 1;

for ( ; ; ) {
// 初始化
FD_ZERO(&rset);
FD_ZERO(&wset);
// 设置感兴趣的事件

// 1.标准输入未结束,且to缓冲区未满——可从stdin读
if (stdineof == 0 && toiptr < &to[MAXLINE])
FD_SET(STDIN_FILENO, &rset); /* read from stdin */
// 2.fr缓冲区未满——可从sockfd读
if (friptr < &fr[MAXLINE])
FD_SET(sockfd, &rset); /* read from socket */
// 3.to缓冲区有数据——可写入套接字
if (tooptr != toiptr)
FD_SET(sockfd, &wset); /* data to write to socket */
// 4.fr缓冲区有数据——可写入stdout
if (froptr != friptr)
FD_SET(STDOUT_FILENO, &wset); /* data to write to stdout */

// 阻塞在select
Select(maxfdp1, &rset, &wset, NULL, NULL);

// 从标准输入read到to
if (FD_ISSET(STDIN_FILENO, &rset)) {
if ( (n = read(STDIN_FILENO, toiptr, &to[MAXLINE] - toiptr)) < 0) {
// 如果发生EWOULDBLOCK,就忽略
// 通常这种错误EWOULDBLOCK不会发生
if (errno != EWOULDBLOCK)
err_sys("read error on stdin");
} else if (n == 0) {
stdineof = 1; /* all done with stdin */
if (tooptr == toiptr)
Shutdown(sockfd, SHUT_WR);/* send FIN */
} else {
toiptr += n; /* # just read */
FD_SET(sockfd, &wset); // to中有数据,则可写入套接字
}
}

// 从套接字read到fr
if (FD_ISSET(sockfd, &rset)) {
if ( (n = read(sockfd, friptr, &fr[MAXLINE] - friptr)) < 0) {
if (errno != EWOULDBLOCK)
err_sys("read error on socket");
} else if (n == 0) {
if (stdineof)
return; /* normal termination */
else
err_quit("str_cli: server terminated prematurely");
} else {
friptr += n; /* # just read */
FD_SET(STDOUT_FILENO, &wset); /* try and write below */
}
}

if (FD_ISSET(STDOUT_FILENO, &wset) && ( (n = friptr - froptr) > 0)) {
if ( (nwritten = write(STDOUT_FILENO, froptr, n)) < 0) {
if (errno != EWOULDBLOCK)
err_sys("write error to stdout");

} else {
froptr += nwritten; /* # just written */
if (froptr == friptr)
froptr = friptr = fr; /* back to beginning of buffer */
}
}

if (FD_ISSET(sockfd, &wset) && ( (n = toiptr - tooptr) > 0)) {
if ( (nwritten = write(sockfd, tooptr, n)) < 0) {
if (errno != EWOULDBLOCK)
err_sys("write error to socket");

} else {
tooptr += nwritten; /* # just written */
if (tooptr == toiptr) {
toiptr = tooptr = to; /* back to beginning of buffer */
if (stdineof)
Shutdown(sockfd, SHUT_WR); /* send FIN */
}
}
}
}
}

image-20230424105350740

14.2.2 str_cli的较简单版本:多进程

每当我们发现需要使用非阻塞式I/O时,更简单的办法通常是把应用程序任务划分到多个进程(使用fork)或多个线程。

下面是str_cli函数的另一个版本,该函数使用fork把当前进程划分成两个进程。这个函数一开始就调用fork把当前进程划分成一个父进程和一个子进程。子进程把来自服务器的文本行复制到标准输出,父进程把来自标准输入的文本行复制到服务器,如图16-9所示。

image-20230424105547673

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include	"unp.h"

void str_cli(FILE *fp, int sockfd) {
pid_t pid;
char sendline[MAXLINE], recvline[MAXLINE];

if ( (pid = Fork()) == 0) { /* child: server -> stdout */
// 读套接字
while (Readline(sockfd, recvline, MAXLINE) > 0)
// 打印在标准输出
Fputs(recvline, stdout);
kill(getppid(), SIGTERM); /* in case parent still running */
exit(0);
}

/* parent: stdin -> server */

// 从标准输入读
while (Fgets(sendline, MAXLINE, fp) != NULL)
// 写套接字
Writen(sockfd, sendline, strlen(sendline));

Shutdown(sockfd, SHUT_WR); /* EOF on stdin, send FIN */
pause();
return;
}

注意该版本相比本节前面给出的非阻塞版本体现的简单性。非阻塞版本同时管理4个不同的I/O流,而且由于这4个流都是非阻塞的,我们不得不考虑对于所有4个流的部分读和部分写问题。然而在fork版本中,每个进程只处理2个I/O流,从一个复制到另一个。这里不需要非阻塞式I/O,因为如果从输入流没有数据可读,往相应的输出流就没有数据可写。

14.2.3 str_cli执行时间

我们已经给出str_cli函数的4个不同版本。以下是调用这些版本以及一个使用线程的版本的TCP客户程序执行时钟时间的汇总,测量环境是从一个Solaris客户主机向RTT为175 毫秒的一个服务器主机复制2000行文本。

  • 354.0秒,停等版本
  • 12.3秒,select加阻塞式I/O版本(6.4节
  • 6.9秒,select加非阻塞式I/O版本(14.2.1节
  • 8.7秒,fork版本(14.2.2节
  • 8.5秒,线程化版本(20.3节

非阻塞版本几乎比select加阻塞式I/O版本快出一倍。fork版本比非阻塞版本稍慢,然而考虑到非阻塞版本代码相比fork版本代码的复杂性,我们推荐简单得多的fork版本。

14.3 非阻塞connect

暂略

14.4 非阻塞accept

暂略

15 ioctl操作

暂略

16 广播

暂略

17 多播

暂略

18 带外数据

暂略

19 信号驱动IO

19.1 概述

信号驱动式I/O是指进程预先告知内核,使得当某个描述符上发生某事时,内核使用信号通知相关进程。

它在历史上曾被称为异步I/O(asynchronous I/O),不过我们讲解的信号驱动式I/O 不是真正的异步I/O。后者通常定义为进程执行I/O系统调用(譬如读或写)告知内核启动某个I/O 操作,内核启动I/O操作后立即返回到进程。进程在I/O操作发生期间继续执行而不会发生阻塞。当操作完成或遇到错误时,内核以进程在I/O系统调用中指定的某种方式通知进程。我们已在6.2节比较了通常可用的各种I/O类型,并指出了信号驱动式I/O和异步I/O之间的差异。

注意,第14大节讲解过的非阻塞式I/O同样不是异步I/O。对于非阻塞式I/O,内核一旦启动I/O操作就不像异步I/O那样立即返回到进程,而是等到I/O操作完成或遇到错误。

POSIX通过aio_XXX函数提供真正的异步I/O,这些函数允许进程指定I/O操作完成时是否由内核产生信号以及产生什么信号。

源自Berkeley的实现使用SIGIO信号支持套接字和终端设备上的信号驱动式I/O。SVR4使用SIGPOLL信号支持流设备上的信号驱动式I/O,SIGPOLL因而等价于SIGIO

19.2 套接字的信号驱动IO

image-20230417100330045

19.2.1 步骤

针对一个套接字使用信号驱动式I/O(SIGIO)要求进程执行以下3个步骤。

  1. 建立SIGIO信号的信号处理函数。
  2. 设置该套接字的属主,通常使用fcntlF_SETOWN命令设置。
  3. 开启该套接字的信号驱动式I/O,通常通过使用fcntlF_SETFL命令打开O_ASYNC标志完成。

19.2.2 UDP套接字的SIGIO信号

在UDP上使用信号驱动式I/O是简单的。

SIGIO信号在发生以下事件时产生:

  1. 数据报到达套接字;
  2. 套接字上发生异步错误。

因此当捕获对于某个UDP套接字的SIGIO信号时,我们调用recvfrom或者读入到达的数据报,或者获取发生的异步错误。

19.2.3 TCP套接字的SIGIO信号

不幸的是,信号驱动式I/O对于TCP套接字近乎无用。问题在于该信号产生得过于频繁,并且它的出现并没有告诉我们发生了什么事件。

下列条件均导致对于一个TCP套接字产生SIGIO信号:

  • 监听套接字上某个连接请求已经完成;
  • 某个断连请求已经发起;
  • 某个断连请求已经完成;
  • 某个连接之半已经关闭;
  • 数据到达套接字;
  • 数据已经从套接字发送走(即输出缓冲区有空闲空间);
  • 发生某个异步错误。

举例来说,如果一个进程既读自又写往一个TCP套接字,那么当有新数据到达时或者当以前写出的数据得到确认时,SIGIO信号均会产生,而且信号处理函数中无法区分这两种情况。

作者能够找到的信号驱动式I/O对于套接字的唯一现实用途是基于UDP的NTP服务器程序。服务器主循环接收来自客户的一个请求数据报并发送回一个应答数据报。然而对于每个客户请求,其处理工作量并非可以忽略(远比我们简单地回射服务器多)。对服务器而言,重要的是为每个收取的数据报记录精确的时间戳,因为该值将返送给客户,由客户用于计算到服务器的RTT。图25-1展示了构建这样的UDP服务器的两种方式。

image-20230424114814659

大多数UDP服务器(包括第8章中的回射服务器)都设计成图中左侧所示的方式,不过NTP 服务器却采用右侧所示的技巧,当一个新的数据报到达时,SIGIO处理函数读入该数据报,同时记录它的到达时刻,然后将它置于进程内的另一个队列中,以便主服务器循环移走并处理。尽管这个技巧让服务器代码变复杂了,却为到达数据报提供了精确的时间戳。

19.3 使用SIGIO的UDP回射服务器程序

main函数不变,唯一修改是dg_echo函数。

全局声明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include	"unp.h"

static int sockfd; // 套接字描述符

#define QSIZE 8 /* size of input queue,最多放入8个数据报 */
#define MAXDG 4096 /* max datagram size */

typedef struct {
void *dg_data; /* ptr to actual datagram */
size_t dg_len; /* length of datagram */
struct sockaddr *dg_sa; /* ptr to sockaddr{} w/client's address */
socklen_t dg_salen; /* length of sockaddr{} */
} DG;
static DG dg[QSIZE]; /* queue of datagrams to process */
static long cntread[QSIZE+1]; /* diagnostic counter */

static int iget; // 主循环将处理的下一个数组元素的下标
static int iput; // 信号处理函数将存放到的下一个数组元素的下标
static int nqueue; // 队列中供主循环处理的数据报的总数
static socklen_t clilen;/* max length of sockaddr{} */

static void sig_io(int); // SIGIO信号处理函数
static void sig_hup(int); // SIGHUP信号处理函数

STGIO信号处理函数把到达的数据报放入一个队列。该队列是一个DG结构数组,我们把它作为一个环形缓冲区处理。

每个DG结构包括指向所收取数据报的一个指针、该数据报的长度、指向含有客户协议地址的某个套接字地址结构的一个指针、该协议地址的大小。静态分配QSIZE个DG结构。还分配一个稍后解释的诊断用计数器cntread。图25-3展示了这个DG结构数组,其中假设第一个元素指向一个150 字节的数据报,与它关联的套接字地址结构长度为16。

image-20230424115755716

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
void dg_echo(int sockfd_arg, SA *pcliaddr, socklen_t clilen_arg) {
int i;
const int on = 1;
// zeromask: 全0(不屏蔽任何信号)
// oldmask: 记录屏蔽SIGIO时原来的信号掩码
sigset_t zeromask, newmask, oldmask;

sockfd = sockfd_arg;
clilen = clilen_arg;

// 初始化已接收数据报队列
for (i = 0; i < QSIZE; i++) { /* init queue of buffers */
dg[i].dg_data = Malloc(MAXDG);
dg[i].dg_sa = Malloc(clilen);
dg[i].dg_salen = clilen;
}
iget = iput = nqueue = 0;

// 注册信号处理函数
Signal(SIGHUP, sig_hup);
Signal(SIGIO, sig_io);
// 设置套接字属主
Fcntl(sockfd, F_SETOWN, getpid());
// 打开信号驱动和非阻塞io标志
Ioctl(sockfd, FIOASYNC, &on);
Ioctl(sockfd, FIONBIO, &on);

// 初始化信号集
Sigemptyset(&zeromask); /* init three signal sets */
Sigemptyset(&oldmask);
Sigemptyset(&newmask);
// 将SIGIO加入到信号集中
Sigaddset(&newmask, SIGIO); /* signal we want to block */
// 屏蔽SIGIO
// 在屏蔽SIGIO之前,产生SIGIO信号(套接字接收到数据报)并且被进程捕获(执行sig_io函数)是可能的,此时nqueue就不为0了
Sigprocmask(SIG_BLOCK, &newmask, &oldmask);
// 每次循环处理一个数据报
for ( ; ; ) {
// 没有待处理的数据报时将进程挂起
// 将信号屏蔽字替换为zeromask,即不屏蔽任何信号
// 当进程捕获到任何一个信号时(例如SIGIO),从该信号的处理函数返回后,sigsuspend才返回,并将信号屏蔽字替换为原始值,即newmask
while (nqueue == 0)
sigsuspend(&zeromask);

// 解除对SIGIO的屏蔽
Sigprocmask(SIG_SETMASK, &oldmask, NULL);

// 发送一个数据报(回射)
Sendto(sockfd, dg[iget].dg_data, dg[iget].dg_len, 0,
dg[iget].dg_sa, dg[iget].dg_salen);

// 到达末尾,又从头开始
if (++iget >= QSIZE)
iget = 0;

// 屏蔽SIGIO
// nqueue是全局变量,需要保护
Sigprocmask(SIG_BLOCK, &newmask, &oldmask);
// 减少一个待处理数据报
nqueue--;
}
}

SIGIO的信号处理函数为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
static void sig_io(int signo) {
ssize_t len;
int nread; // 每次读到的数据报个数
DG *ptr;

for (nread = 0; ; ) {
if (nqueue >= QSIZE)
err_quit("receive overflow");
// 获取队列中下一个可用位置
ptr = &dg[iput];
ptr->dg_salen = clilen;
// 接收的字节数
len = recvfrom(sockfd, ptr->dg_data, MAXDG, 0, ptr->dg_sa, &ptr->dg_salen);
if (len < 0) {
if (errno == EWOULDBLOCK) // 套接字上没有可读的数据
break; /* all done; no more queued to read */
else
err_sys("recvfrom error");
}
ptr->dg_len = len;

nread++;
nqueue++; // 队列中的数据报数加1
if (++iput >= QSIZE) // 环形
iput = 0;

}
// 例如执行了一次SIGIO处理函数后,nread = 2,则cntread[2]计数加1
cntread[nread]++;
}

编写本信号处理函数时我们遇到的问题是POSLX信号通常不排队(1-31编号的信号为标准信号,或者不可靠信号,SIGIO编号为29)。这一点意味着如果我们在信号处理函数中执行(期间内核确保该信号被阻塞),期间该信号又发生了2次(套接字又接收了两个数据报),那么它实际只被递交1次。

让我们考虑下述情形。一个数据报到达导致SIGIO被递交。它的信号处理函数读入该数据报并把它放到供主循环读取的队列中。然而在信号处理函数执行期间,另有两个数据报到达,导致SIGIO再产生两次。由于SIGIO被阻塞,当它的信号处理函数返回时,该处理函数仅仅再被调用一次。该信号处理函数的第二次执行读入第二个数据报,第三个数据报则仍然留在套接字接收队列中。第三个数据报被读入的前提条件是有第四个数据报到达。当第四个数据报到达时,被读入并放到供主循环读取的队列中的是第三个而不是第四个数据报。

既然信号是不排队的,开启信号驱动式I/O的描述符通常也被设置为非阻塞模式。这个前提下,我们把SIGIO信号处理函数编写成在一个循环中执行读入操作,直到该操作返回EWOULDBLOCK 时才结束循环。


SIGHUP的信号处理函数为:

1
2
3
4
5
6
static void sig_hup(int signo) {
int i;

for (i = 0; i <= QSIZE; i++)
printf("cntread[%d] = %ld\n", i, cntread[i]);
}

执行结果和分析

为了说明信号是不排队的,并且除了设置套接字的信号驱动式I/O标志之外,还必须把套接字设置为非阻塞式,我们与6个客户一道运行本服务器。每个客户发送3645行让服务器回射的文本,而且每个客户都从同一个shell脚本以后台方式启动,因而所有客户几乎在同一时刻启动。所有客户终止之后,我们向服务器发送SIGHUP信号,促使它显示cntread数组内容。

1
2
3
4
5
6
7
8
9
10
linux % udpserv01 
cntread[0] = 0 # 执行1次SIGIO信号处理,接收到0个数据报的次数为0
cntread[1] = 15899 # 执行1次SIGIO信号处理,接收到1个数据报的次数为15899,以此类推
cntread[2] = 2099
cntread[3] = 515
cntread[4] = 57
cntread[5] = 0
cntread[6] = 0
cntread[7] = 0
cntread[8] = 0

大多数情况下信号处理函数每次被调用只读入一个数据报,不过有些情况下可读入多个数据报。cntread[0]计数器不为0是可能的:这些信号在信号处理函数正在执行时产生,不过信号处理函数的本次执行在返回之前预先读入了对应这些信号的数据报,当信号处理函数因这些信号的提交而再次被调用执行时,已经没有剩余的数据报可以读入了(产生EWOULDBLOCK错误)。

最后,我们可以验证该数组元素的加权总和$$15899×1+2099×2+515×3+57×4=21870$$等于6(客户数目)乘以3645 (每个客户的发送的文本行数)。

19.4 小结

信号驱动式I/O就是让内核在套接字上发生”某事”时使用SIGIO信号通知进程。

  • 对于已连接TCP套接字,可以导致这种通知的条件为数众多,反而使得这个特性几近无用。
  • 对于监听TCP套接字,这种通知发生在有一个新连接已准备好接受之时。
  • 对于UDP套接字,这种通知意味着或者到达一个数据报,或者到达一个异步错误,这两种情况下我们都调用recvfrom

我们把早先的UDP回射服务器程序改为使用信号驱动式I/O,所用技巧类似于NTP,尽快读入已到达的每个数据报以获取其到达时刻的精确时间戳,然后将它置于某个队列供后续处理。

20 线程

20.1 概述

线程:轻量级进程。

同一进程内的所有线程共享相同的全局内存。这使得线程之间易于共享信息,然而伴随这种简易性而来的却是同步(synchronization)问题。

同一进程内的所有线程除了共享全局变量外还共享:

  • 进程指令
  • 大多数数据
  • 打开的文件(即描述符)
  • 信号处理函数和信号处置
  • 当前工作目录
  • 用户ID和组ID

不过每个线程有各自的:

  • 线程ID;
  • 寄存器集合,包括程序计数器和栈指针
  • 栈(用于存放局部变量和返回地址)
  • errno
  • 信号掩码
  • 优先级

本章讲解的是POSIX线程,也称为Pthread。POSIX线程作为POSIX.1c标准的一部分在1995年得到标准化,大多数UNIX版本将来会支持这类线程。我们将看到所有Pthread函数都以pthread_打头。

20.2 创建和终止

20.2.1 pthread_create函数

当一个程序由exec启动执行时,称为初始线程(initial thread)或主线程(main thread)的单个线程就创建了。其余线程则由pthread_create函数创建。

1
2
3
#include <pthread.h>
// 0, errno
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);
  • thread:事先创建好的pthread_t类型的参数。成功时thread指向的内存单元被设置为新创建线程的线程ID。
  • attr:用于定制各种不同的线程属性。APUE的12.3节讨论了线程属性。通常直接设为NULL。
  • start_routine:新创建线程从此函数开始运行,无参数时arg设为NULL即可。形参是函数指针(该函数返回值和形参均为void*),因此需要传入函数地址。
  • argstart_rtn函数的参数。无参数时设为NULL即可。有参数时输入参数的地址。当多于一个参数时应当使用结构体传入。

20.2.2 pthread_join函数

我们可以通过调用pthread_join等待一个给定线程终止。对比线程和UNIX进程,pthread_create类似于fork,pthread_join类似于waitpid。

1
2
3
#include <pthread.h>
// 0, errno
int pthread_join(pthread_t thread, void **retval);
  • thread:为被等待的线程标识符,因此pthread_join没有办法等待任意一个线程
  • retval:为用户定义的指针,它可以用来存储被等待线程的返回值,即pthread_exit的参数。这是一个二级指针,因此传入的参数为一级指针的地址,如果不关心返回值则用NULL

20.2.3 pthread_self函数

每个线程使用pthread_self获取自身的线程ID。

1
2
3
#include <pthread.h>

pthread_t pthread_self(void);

20.2.4 pthread_detach函数

一个线程或者是可汇合的(joinable,默认值),或者是脱离的(detached)。

当一个可汇合的线程终止时,它的线程ID和退出状态将留存到另一个线程对它调用pthread_join,如果没有线程对该可汇合的线程调用pthread_join,则该可汇合线程成为僵尸线程。

脱离的线程却像守护进程,当它们终止时,所有相关资源都被释放,避免成为僵尸线程,我们不能等待它们终止。如果一个线程需要知道另一个线程什么时候终止,那就最好保持第二个线程的可汇合状态。

pthread_detach函数把指定的线程转变为脱离状态。

1
2
3
#include <pthread.h>
// 0, errno
int pthread_detach(pthread_t tid);

本函数通常由想让自己脱离的线程调用,就如以下语句:

1
pthread_detach(pthread_self());

20.2.5 pthread_exit函数

让线程终止的方法之一。

1
2
3
#include <pthread.h>

void pthread_exit(void *status);

如果本线程未曾脱离,它的线程ID和退出状态将一直留存到调用进程内的某个其他线程对它调用pthread_join

让一个线程终止的另外两个方法是。

  • 启动线程的函数(即pthread_create的第三个参数)返回。既然该函数必须声明成返回一个void指针,它的返回值就是相应线程的终止状态。
  • 如果进程的main函数返回或者任何线程调用了exit,整个进程就终止,其中包括它的任何线程。

20.3 使用线程的str_cli函数

image-20230422105117208

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include	"unpthread.h"

// 线程执行函数的定义
void *copyto(void *);

// 定义为全局静态变量,使得线程也可以访问
static int sockfd; /* global for both threads to access */
static FILE *fp;

// fp_arg == stdin
void str_cli(FILE *fp_arg, int sockfd_arg) {
char recvline[MAXLINE];
pthread_t tid; // 线程id

sockfd = sockfd_arg; /* copy arguments to externals */
fp = fp_arg;

Pthread_create(&tid, NULL, copyto, NULL);
// 从套接字读取数据并输出到标准输出中
while (Readline(sockfd, recvline, MAXLINE) > 0)
Fputs(recvline, stdout);
}

void *copyto(void *arg) {
char sendline[MAXLINE];
// 从标准输入中读,然后写入到套接字中
while (Fgets(sendline, MAXLINE, fp) != NULL)
Writen(sockfd, sendline, strlen(sendline));

// 读到EOF,关闭套接字
Shutdown(sockfd, SHUT_WR); /* EOF on stdin, send FIN */
// 返回,导致线程终止,返回值是线程的终止状态,这里返回NULL
return(NULL); /* return (i.e., thread terminates) when EOF on stdin */
}

20.4 使用线程的TCP回射服务器程序

20.4.1 代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#include	"unpthread.h"

static void *doit(void *); /* each thread executes this function */

int main(int argc, char **argv) {
int listenfd, connfd;
pthread_t tid;
socklen_t addrlen, len;
struct sockaddr *cliaddr;

if (argc == 2)
listenfd = Tcp_listen(NULL, argv[1], &addrlen);
else if (argc == 3)
listenfd = Tcp_listen(argv[1], argv[2], &addrlen);
else
err_quit("usage: tcpserv01 [ <host> ] <service or port>");

cliaddr = Malloc(addrlen);

for ( ; ; ) {
len = addrlen;
connfd = Accept(listenfd, cliaddr, &len);
// 创建线程处理每个连接
// 传递给doit函数的唯一参数为副套接字connfd
// 将int类型强制转换为void *(将一个int强转为一个地址值)
Pthread_create(&tid, NULL, &doit, (void *) connfd);
}
}

static void *doit(void *arg) {
// 分离线程
Pthread_detach(pthread_self());
// 处理任务
str_echo((int) arg); /* same function as before */
// 和fork不同的是,这里必须关闭副套接字
// fork的情形:子进程exit后会自动关闭打开的描述符
// 而线程return后,线程终止,并不会关闭打开的描述符
Close((int) arg); /* done with connected socket */
return(NULL);
}

注意:线程共享描述符,因此不会像fork那样,主进程和子进程需要关闭不需要的描述符。

20.4.2 给新线程传递参数

把整数变量connfd类型强制转换成void指针并不保证在所有系统上都能起作用。要正确地处理这一点需要做额外的工作。

首先注意我们不能简单地把connfa的地址传递给新线程。也就是说如下代码并不起作用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int main(int argc, char **argv) {
int listenfd, connfd;

// ...

for ( ; ; ) {
len = addrlen;
connfd = Accept(listenfd, cliaddr, &len);
Pthread_create(&tid, NULL, &doit, &connfd);
}
}


static void *doit(void *arg) { // void *arg = &connfd;
int connfd;
// 先强转为int *后再解引用
// void *必须强转后才能解引用
connfd = *((int *)arg);
Pthread_detach(pthread_self());
str_echo((int) arg);
Close((int) arg);
return(NULL);
}

主线程中只有一个整数变量connfd,每次调用accept该变量都会被覆写以一个新值(已连接描述符)。因此可能发生下述情况:

  • accept返回,主线程把返回值(譬如说新的描述符是5)存入connfd后调用pthread_create。
  • Pthread函数库创建一个线程,并准备调度doit函数启动执行。
  • 另一个连接就绪且主线程在新创建的线程开始运行之前再次运行。accept返回,主线程把返回值(譬如说新的描述符现在是6)存入connfd后调用pthread_create。

尽管主线程共创建了两个线程,但是它们操作的都是存放在connfd中的最终值(6)。问题出在多个线程不是同步地访问一个共享变量(以取得存放在connfd中的整数值)。

解决:使用动态内存存储每次得到的副套接字

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int main(int argc, char **argv) {
int listenfd, connfd;

// ...

for ( ; ; ) {
len = addrlen;
iptr = Malloc(sizeof(int));
*iptr = Accept(listenfd, cliaddr, &len);
Pthread_create(&tid, NULL, &doit, iptr);
}
}


static void *doit(void *arg) { // void *arg = iptr;
int connfd;
connfd = *((int *)arg);
free(arg);
Pthread_detach(pthread_self());
str_echo((int) arg);
Close((int) arg);
return(NULL);
}

malloc和free这两个函数历来是不可重入的(不能由超过一个任务所共享,除非能确保函数的互斥)。换句话说,在主线程正处于这两个函数之一的内部处理期间,从某个信号处理函数中调用这两个函数之一有可能导致灾难性的后果,这是因为这两个函数操纵相同的静态数据结构。既然如此,我们如何才能调用这两个函数呢?POSIX要求这两个函数以及许多其他函数都是线程安全的(thread-safe)。这个要求通常通过在对我们透明的库函数内部执行某种形式的同步达到。

20.5 线程特定数据

暂略

21 客户/服务器程序设计范式

21.1 概述

当开发一个Unix服务器程序时,我们有如下类型的进程控制可供选择:

  • 迭代服务器程序(1.5节),不过这种类型的适用情形极为有限,因为这样的服务器在完成对当前客户的服务之前无法处理已等待服务的新客户。
  • 第一个并发服务器程序(5.2节),它为每个客户调用fork派生一个子进程。传统上大多数Unix服务器程序属于这种类型。
  • 6.8节,另一个版本的TCP服务器程序由使用select处理任意多个客户的单个进程构成。
  • 20.4节,并发服务器程序被改为服务器为每个客户创建一个线程,以取代派生一个进程。

我们将在本章探究并发服务器程序设计的另两类变体:

  • 预先派生子进程(preforking)是让服务器在启动阶段调用fork创建一个子进程池。每个客户请求由当前可用子进程池中的某个(闲置)子进程处理。
  • 预先创建线程(prethreading)是让服务器在启动阶段创建一个线程池,每个客户由当前可用线程池中的某个(闲置)线程处理。

我们将在本章审视预先派生子进程和预先创建线程这两种类型的众多细节:如果池中进程和线程不够多怎么办?如果池中进程和线程过多怎么办?父进程与子进程之间以及各个线程之间怎样彼此同步?

各范式比较

image-20230422114223569

image-20230422214424227

image-20230422114315542

21.2 TCP客户程序设计范式

客户程序的编写通常比服务器程序容易些,因为客户中进程控制要少得多。

我们已经探究了客户程序的各种设计范式,这里有必要汇总它们各自的优缺点:

  • 5.3节是基本的TCP客户程序。该程序存在两个问题。首先,进程在被阻塞以等待用户输入期间,看不到诸如对端关闭连接等网络事件。其次,它以停-等模式运作,批处理效率极低。
  • 6.4节是下一个迭代客户程序,它通过调用select使得进程能够在等待用户输入期间得到网络事件通知。然而该程序存在不能正确地处理批量输入的问题。
  • 6.7节通过使用shutdown函数解决了这个问题。
  • 14.2.1节给出的是使用非阻塞式I/O实现的客户程序
  • 14.2.2节给出第一个超越单进程单线程设计范畴的客户程序,它使用fork派生一个子进程,并由父进程(或子进程)处理从客户到服务器的数据,由子进程(或父进程)处理从服务器到客户的数据。
  • 20.3节是使用两个线程取代两个进程的客户程序

14.2.3节汇总了这些不同版本之间在测时结果上的差异。非阻塞式I/O版本尽管是最快的,其代码却比较复杂;使用两个进程或两个线程的版本相比之下代码简化得多,而运行速度只是稍逊而已。

21.3 TCP测试用客户程序

给出的客户程序用于测试我们的服务器程序的各个变体。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#include	"unp.h"

#define MAXN 16384 /* max # bytes to request from server */

int main(int argc, char **argv) {
int i, j, fd, nchildren, nloops, nbytes;
pid_t pid;
ssize_t n;
char request[MAXLINE], reply[MAXN];

if (argc != 6)
err_quit("usage: client <hostname or IPaddr> <port> <#children> "
"<#loops/child> <#bytes/request>");

nchildren = atoi(argv[3]); // 客户fork的子进程数
nloops = atoi(argv[4]); // 每个子进程发送给服务器的请求数
nbytes = atoi(argv[5]); // 每个请求要求服务器返送的数据字节数
snprintf(request, sizeof(request), "%d\n", nbytes); /* newline at end */

for (i = 0; i < nchildren; i++) {
if ( (pid = Fork()) == 0) { /* child */
// 一个子进程发送nloops个请求
for (j = 0; j < nloops; j++) {
// 发起TCP连接
fd = Tcp_connect(argv[1], argv[2]);
// 写套接字
Write(fd, request, strlen(request));
// 读取服务器回传的数据
if ( (n = Readn(fd, reply, nbytes)) != nbytes)
err_quit("server returned %d bytes", n);
// 关闭套接字
Close(fd); /* TIME_WAIT on client, not server */
}
printf("child %d done\n", i);
// 子进程退出
exit(0);
}
/* parent loops around to fork() again */
}

while (wait(NULL) > 0) /* now parent waits for all children */
;
if (errno != ECHILD)
err_sys("wait error");
// 主进程退出
exit(0);
}

例如:

1
% client 206.62.226.36 8888 5 500 4000

这将建立2500个与服务器的TCP连接:5个子进程各自发起500次连接。在每个连接上,客户向服务器发送5字节数据4000\n,服务器向客户返送4000字节数据。

我们在两个不同的主机上针对同一个服务器执行本客户程序,于是总共提供5000个TCP连接,而且任意时刻服务器端最多同时存在10个连接。

21.4 TCP迭代服务器程序

我们在本章中比较各个范式服务器程序时迭代服务器程序的用途却不可磨灭。如果我们针对迭代服务器如下执行用于测试的客户程序:

1
% client 206.62.226.36 8888 1 5000 4000

我们得到同样数目的TCP连接(5000个),跨每个连接传送的数据量也相同。然而由于服务器是迭代的,它没有执行任何进程控制。这就让我们测量出服务器处理如此数目客户所需CPU时间的一个基准值,从其他服务器的实测CPU时间中减去该值就能得到它们的进程控制时间。从进程控制角度看迭代服务器是最快的,因为它不执行进程控制。有了基准值之后,我们在图30-1中比较各个实测CPU时间与基准值的差值。

21.5 TCP并发服务器程序,每个客户一个子进程

并发服务器的问题在于为每个客户现场fork一个子进程比较耗费CPU时间。多年前(20世纪80年代后期)当一个繁忙的服务器每天也就处理几百个亦或几千个客户时,这点CPU时间是可以接受的。然而Web应用的爆发式增长改变了人们的态度。繁忙的Web服务器每天测得TCP连接数以百万计。这还是就单个主机而言,更繁忙的站点往往运行多个主机来分摊负荷。以后若干节讲解各种技术以避免并发服务器为每个客户现场fork的做法,不过传统意义上的并发服务器依然相当普遍。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
#include	"unp.h"

int main(int argc, char **argv) {
int listenfd, connfd;
pid_t childpid;
void sig_chld(int), sig_int(int), web_child(int);
socklen_t clilen, addrlen;
struct sockaddr *cliaddr;

if (argc == 2)
listenfd = Tcp_listen(NULL, argv[1], &addrlen);
else if (argc == 3)
listenfd = Tcp_listen(argv[1], argv[2], &addrlen);
else
err_quit("usage: serv01 [ <host> ] <port#>");
cliaddr = Malloc(addrlen);

// 注册信号处理函数sig_chld,见5.2.2节
Signal(SIGCHLD, sig_chld);
// 当键入终端中断键时,捕获该信号
Signal(SIGINT, sig_int);

for ( ; ; ) {
clilen = addrlen;
if ( (connfd = accept(listenfd, cliaddr, &clilen)) < 0) {
if (errno == EINTR)
continue; /* back to for() */
else
err_sys("accept error");
}
// fork子进程
if ( (childpid = Fork()) == 0) { /* child process */
Close(listenfd); /* close listening socket */
// 处理任务
web_child(connfd); /* process request */
exit(0);
}
Close(connfd); /* parent closes connected socket */
}
}

void sig_int(int signo) {
void pr_cpu_time(void);
// 用于计算CPU时间,实现略
pr_cpu_time();
// 使得进程终止
exit(0);
}

web_child函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include	"unp.h"

#define MAXN 16384 /* max # bytes client can request */

void web_child(int sockfd) {
int ntowrite; // 需要写的字节数
ssize_t nread; // 读到的字节数
char line[MAXLINE], result[MAXN];

for ( ; ; ) {
// 读套接字
// 客户端传过来的是字节数,例如4000\n\0
// 因此服务器读到line中的内容为4000\n\0 nread=6
if ( (nread = Readline(sockfd, line, MAXLINE)) == 0)
return; /* connection closed by other end */

/* line from client specifies #bytes to write back */
ntowrite = atol(line); // ntowrite = 4000
if ((ntowrite <= 0) || (ntowrite > MAXN))
err_quit("client request for %d bytes", ntowrite);
// 从缓冲区result写ntowrite个字节数到套接字中
Writen(sockfd, result, ntowrite);
}
}

图30-1行1给出了我们的并发服务器程序的测时结果。相比后续各行,我们看到传统意义的并发服务器所需CPU时间最多,与它为每个客户现场fork的做法相吻合。

21.6 TCP预先派生子进程服务器程序,accept无上锁保护

21.6.1 一般实现

我们的第一个”增强”型服务器程序使用称为预先派生子进程preforking)的技术。使用该技术的服务器不像传统意义的并发服务器那样为每个客户现场派生一个子进程,而是在启动阶段预先派生一定数量的子进程,当各个客户连接到达时,这些子进程立即就能为它们服务。图30-8展示了服务器父进程预先派生出N个子进程且正有2个客户连接着的情形。

image-20230422190757194

这种技术的优点在于无须引入父进程执行fork的开销就能处理新到的客户。缺点则是父进程必须在服务器启动阶段猜测需要预先派生多少子进程。如果某个时刻客户数恰好等于子进程总数,那么新到的客户将被忽略,直到至少有一个子进程重新可用。然而回顾4.5节,我们知道这些客户并未被完全忽略。内核将为每个新到的客户完成三路握手,直到达到相应套接字上listen调用的backlog数为止,然后在服务器调用accept时把这些已完成的连接传递给它。这么一来客户就能觉察到服务器在响应时间上的恶化,因为尽管它的connect调用可能立即返回(对客户端,完成三路握手后,connect就能立即返回;对服务器,该连接进入已完成请求队列,等待调用accept从队列中取出)但是它的第一个请求(等待服务器从已完成请求队列中取出)可能是在一段时间之后才被服务器处理。

通过增加一些代码,服务器总能应对客户负载的变动。父进程必须做的就是持续监视可用(即闲置)子进程数,一旦该值降到低于某个阈值就派生额外的子进程。同样,一旦该值超过另一个阈值就终止一些过剩的子进程,因为在本章后面我们会发现过多的可用子进程也会导致性能退化。

不过在考虑这些增强之前,我们首先查看这类服务器程序的基本结构。下面给出我们的预先派生子进程服务器程序第一个版本的main函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#include	"unp.h"

static int nchildren; // 预先生成的子进程数
static pid_t *pids; // 子进程pid数组

int main(int argc, char **argv) {
int listenfd, i;
socklen_t addrlen;
void sig_int(int);
pid_t child_make(int, int, int);

// 监听对端的SYN分节
if (argc == 3)
listenfd = Tcp_listen(NULL, argv[1], &addrlen);
else if (argc == 4)
listenfd = Tcp_listen(argv[1], argv[2], &addrlen);
else
err_quit("usage: serv02 [ <host> ] <port#> <#children>");
nchildren = atoi(argv[argc-1]);
pids = Calloc(nchildren, sizeof(pid_t));

// 创建nchildren个子进程
for (i = 0; i < nchildren; i++)
pids[i] = child_make(i, listenfd, addrlen); /* parent returns */

Signal(SIGINT, sig_int);

for ( ; ; )
pause(); /* everything done by children */
}

// 杀死所有子进程并wait
void sig_int(int signo) {
int i;
void pr_cpu_time(void);

/* terminate all children */
for (i = 0; i < nchildren; i++)
kill(pids[i], SIGTERM);
while (wait(NULL) > 0) /* wait for all children */
;
if (errno != ECHILD)
err_sys("wait error");

pr_cpu_time();
exit(0);
}

调用child_make创建子进程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include	"unp.h"

/**
* i: 子进程序号
* listenfd: 监听套接字
* addrlen: 套接字地址长度
*/
pid_t child_make(int i, int listenfd, int addrlen) {
pid_t pid;
void child_main(int, int, int);

if ( (pid = Fork()) > 0)
return(pid); /* parent */

// 子进程
child_main(i, listenfd, addrlen); /* never returns */
}

// 一个子进程接收一个连接,并处理任务
void child_main(int i, int listenfd, int addrlen) {
int connfd;
void web_child(int);
socklen_t clilen;
struct sockaddr *cliaddr;

cliaddr = Malloc(addrlen);

printf("child %ld starting\n", (long) getpid());
for ( ; ; ) {
clilen = addrlen;
// 每个子进程都阻塞在此处,等待接收已完成的连接
connfd = Accept(listenfd, cliaddr, &clilen);
// 处理任务
web_child(connfd); /* process the request */
Close(connfd);
}
}

21.6.2 BSD4.4实现

上面的实现中,多个进程在同一个监听套接字listenfd上调用accept。

父进程在派生任何子进程之前创建监听套接字,而每次调用fork时,所有描述符也被复制。图30-13展示了proc结构(每个进程一个)、监听描述符的单个file结构以及单个socket结构之间的关系。

image-20230422192755050

描述符只是本进程引用file结构的proc结构中一个数组中某个元素的下标而已。fork调用执行期间为子进程复制描述符的特性之一是:子进程中一个给定描述符引用的file结构正是父进程中同一个描述符引用的file结构。每个file结构都有一个引用计数。当打开一个文件或套接字时,内核将为之构造一个file结构,并由作为打开操作返回值的描述符引用,它的引用计数初值自然为1;以后每当调用fork以派生子进程或对打开操作返回的描述符(或其复制品)调用dup以复制描述符时,该file结构的引用计数就递增(每次增1)。在我们的N个子进程的例子中,file结构的引用计数为N+1(别忘了父进程仍然保持该监听描述符打开着,不过它从不调用accept)。

服务器进程在程序启动阶段派生N个子进程,它们各自调用accept并因而均被内核投入睡眠。当第一个客户连接到达时,所有N个子进程均被唤醒。这是因为所有N个子进程所用的监听描述符(它们有相同的值)指向同一个socket结构,致使它们在同一个等待通道(wait channel)即这个socket结构的so_timeo成员上进入睡眠。尽管所有N个子进程均被唤醒,其中只有最先运行的子进程获得那个客户连接,其余N-1个子进程继续恢复睡眠,因为当它们发现已完成连接的队列长度为0(因为最先运行的连接早已取走了本就只有一个的连接)。

这就是有时候称为惊群(thundering herd)的问题,因为尽管只有一个子进程将获得连接,所有N个子进程却都被唤醒了。尽管如此这段代码依然起作用,只是每当仅有一个连接准备好被接受时却唤醒太多进程的做法会导致性能受损。

21.6.3 select冲突

当多个进程在引用同一个套接字的描述符上调用select时就会发生冲突,因为在socket结构中为存放本套接字就绪之时应该唤醒哪些进程而分配的仅仅是一个进程ID的空间。如果有多个进程在等待同一个套接字,那么内核必须唤醒的是阻塞在select调用中的所有进程,因为它不知道哪些进程受刚变得就绪的这个套接字影响

image-20230422213955869

从以上讨论我们可以得出如下经验:如果有多个进程阻塞在引用同一个实体(例如套接字或普通文件,由file结构直接或间接描述)的描述符上,那么最好直接阻塞在诸如accept之类的函数而不是select之中。

21.7 TCP预先派生子进程服务器程序,accept使用文件上锁保护

4.4BSD实现允许多个进程在引用同一个监听套接字的描述符上调用accept,然而这种做法也仅仅适用于在内核中实现accept的源自Berkeley的内核。相反,作为一个库函数实现accept的System V内核可能不允许这么做。事实上如果我们在基于SVR4的Solaris2.5内核上运行上一节的服务器程序,那么客户开始连接到该服务器后不久,某个子进程的accept就会返回EPROTO错误(表示协议有错)。

解决办法是让应用进程在调用accept前后安置某种形式的锁(lock),这样任意时刻只有一个子进程阻塞在accept调用中,其他子进程则阻塞在试图获取用于保护accept的锁上。

有多种方法可用于提供包绕accept调用的上锁功能。本节我们使用以fcntl函数呈现的POSIX文件上锁功能。

代码改动

main函数:在派生子进程的循环之前,增加一个my_lock_init函数的调用

1
2
3
my_lock_init("/tmp/lock.XXXXXX");
for(i = 0; i < nchildren; i++)
pids[i] = child_make(i, listenfd, addrlen);

child_make函数不变;

child_main函数:在调用accept之前获取文件锁,在返回之后释放文件锁。

1
2
3
4
5
6
7
for( ; ; ) {
clilen = addrlen;
my_lock_wait(); // 上锁
connfd = Accept(...);
my_lock_release(); // 解锁
// ....
}

my_lock_init函数实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include	"unp.h"

static struct flock lock_it, unlock_it;
static int lock_fd = -1;
/* fcntl() will fail if my_lock_init() not called */

void my_lock_init(char *pathname) {
char lock_file[1024];

/* 4must copy caller's string, in case it's a constant */
strncpy(lock_file, pathname, sizeof(lock_file));
lock_fd = Mkstemp(lock_file);

Unlink(lock_file); /* but lock_fd remains open */

lock_it.l_type = F_WRLCK;
lock_it.l_whence = SEEK_SET;
lock_it.l_start = 0;
lock_it.l_len = 0;

unlock_it.l_type = F_UNLCK;
unlock_it.l_whence = SEEK_SET;
unlock_it.l_start = 0;
unlock_it.l_len = 0;
}

void my_lock_wait() {
int rc;

while ( (rc = fcntl(lock_fd, F_SETLKW, &lock_it)) < 0) {
if (errno == EINTR)
continue;
else
err_sys("fcntl error for my_lock_wait");
}
}

void my_lock_release() {
if (fcntl(lock_fd, F_SETLKW, &unlock_it) < 0)
err_sys("fcntl error for my_lock_release");
}

现在这个新版本的预先派生子进程服务器程序在SVR4系统上照样可以工作,因为它保证每次只有一个子进程阻塞在accept调用中。对比图30-1中Digital Unix和BSD/OS服务器的行2和行3,我们看到这种围绕accept的上锁增加了服务器的进程控制CPU时间。

21.8 TCP预先派生子进程服务器程序,accept使用线程上锁保护

21.9 TCP预先派生子进程服务器程序,传递描述符

对预先派生子进程服务器程序的最后一个修改版本是只让父进程调用accept,然后把所接受的已连接套接字传递给某个子进程。这么做绕过了为所有子进程的accept调用提供上锁保护的可能需求,不过需要从父进程到子进程的某种形式的描述符传递。这种技术会使代码多少有点复杂,因为父进程必须跟踪子进程的忙闲状态,以便给空闲子进程传递新的套接字。

在以前的预先派生子进程的例子中,父进程无需关心由哪个子进程接收一个客户连接。操作系统处理这个细节,给予某个子进程以首先调用accept的机会,或者给予某个子进程以所需的文件锁或互斥锁。图30-2的前5栏同时表明我们测量的3个操作系统以公平的轮循方式执行这种选择。

暂略

21.10 TCP并发服务器程序,每个客户一个线程

如果服务器主机支持线程,可以改用线程以取代子进程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#include	"unpthread.h"

int main(int argc, char **argv) {
int listenfd, connfd;
void sig_int(int);
void *doit(void *);
pthread_t tid;
socklen_t clilen, addrlen;
struct sockaddr *cliaddr;

if (argc == 2)
listenfd = Tcp_listen(NULL, argv[1], &addrlen);
else if (argc == 3)
listenfd = Tcp_listen(argv[1], argv[2], &addrlen);
else
err_quit("usage: serv06 [ <host> ] <port#>");
cliaddr = Malloc(addrlen);

Signal(SIGINT, sig_int);

for ( ; ; ) {
clilen = addrlen;
connfd = Accept(listenfd, cliaddr, &clilen);
// 创建线程
Pthread_create(&tid, NULL, &doit, (void *) connfd);
}
}

void *doit(void *arg) {
void web_child(int);
// 线程分离
Pthread_detach(pthread_self());
// 执行任务
web_child((int) arg);
// 关闭套接字
Close((int) arg);
return(NULL);
}

void sig_int(int signo) {
void pr_cpu_time(void);

pr_cpu_time();
exit(0);
}

图30-1表明这个简单的创建线程版本在Solaris和Digital Unix上都快于所有预先派生子进程的版本。此外,这个为每个客户现场创建一个线程的版本比为每个客户现场派生一个子进程的版本(行1)快许多倍。

21.11 TCP预先派生线程服务器程序,每个线程各自accept

使用互斥锁以保证任何时刻只有一个线程在调用accept。

  • pthread07.h定义了用于维护关于每个线程若干信息的Thread结构
1
2
3
4
5
6
7
8
9
typedef struct {
pthread_t thread_tid; /* thread ID */
long thread_count; /* # connections handled */
} Thread;
Thread *tptr; /* array of Thread structures; calloc'ed */

int listenfd, nthreads; // 线程池的线程数量
socklen_t addrlen;
pthread_mutex_t mlock; // 互斥锁
  • main函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include	"unpthread.h"
#include "pthread07.h"

// 静态初始化互斥锁
pthread_mutex_t mlock = PTHREAD_MUTEX_INITIALIZER;

int main(int argc, char **argv) {
int i;
void sig_int(int), thread_make(int);

if (argc == 3)
listenfd = Tcp_listen(NULL, argv[1], &addrlen);
else if (argc == 4)
listenfd = Tcp_listen(argv[1], argv[2], &addrlen);
else
err_quit("usage: serv07 [ <host> ] <port#> <#threads>");
nthreads = atoi(argv[argc-1]);
// 分配nthreads个空间
tptr = Calloc(nthreads, sizeof(Thread));
// 预先创建线程
for (i = 0; i < nthreads; i++)
thread_make(i); /* only main thread returns */

Signal(SIGINT, sig_int);

for ( ; ; )
pause(); /* everything done by threads */
}
  • thread_makethread_main函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include	"unpthread.h"
#include "pthread07.h"

void thread_make(int i) {
void *thread_main(void *);

Pthread_create(&tptr[i].thread_tid, NULL, &thread_main, (void *) i);
return; /* main thread returns */
}

// 线程执行函数
void *thread_main(void *arg) {
int connfd;
void web_child(int);
socklen_t clilen;
struct sockaddr *cliaddr;

cliaddr = Malloc(addrlen);

printf("thread %d starting\n", (int) arg);
for ( ; ; ) {
clilen = addrlen;
Pthread_mutex_lock(&mlock); // 加锁
connfd = Accept(listenfd, cliaddr, &clilen);
Pthread_mutex_unlock(&mlock); // 解锁
tptr[(int) arg].thread_count++; // 计数

web_child(connfd); /* process request */
Close(connfd);
}
}

图30-2给出了Thread结构中thread_count计数器值的分布,它们由SIGINT信号处理函数在服务器终止前显示输出。这个分布的均衡性是由线程调度算法带来的,该算法在选择由哪个线程接收互斥锁上表现为按顺序轮循所有线程。

21.12 TCP预先派生线程服务器程序,主线程统一accept

最后一个使用线程的服务器程序设计范式是在程序启动阶段创建一个线程池之后只让主线程调用accept并把每个客户连接传递给池中某个可用线程。这一点类似于21.9节的描述符传递版本。

本设计范式的问题在于主线程如何把一个已连接套接字传递给线程池中某个可用线程。这里有多个实现手段。我们原本可以如前使用描述符传递,不过既然所有线程和所有描述符都在同一个进程之内,我们没有必要把一个描述符从一个线程传递到另一个线程。接收线程只需知道这个已连接套接字描述符的值,而描述符传递实际传递的并非这个值,而是对这个套接字的一个引用,因而将返回一个不同于原值的描述符(该套接字的引用计数也被递增)。

  • pthread08.h定义了用于维护关于每个线程若干信息的Thread结构
1
2
3
4
5
6
7
8
9
10
11
12
13
typedef struct {
pthread_t thread_tid; /* thread ID */
long thread_count; /* # connections handled */
} Thread;
Thread *tptr; /* array of Thread structures; calloc'ed */

#define MAXNCLI 32 // 最大可连接数
// clifd: 由主线程向其中存入已接受的已连接套接字描述符
// iget: 线程可取出描述符的位置, iput: 主线程的下一个可用存放位置
int clifd[MAXNCLI], iget, iput;
// clifd, iget, iput对于所有线程共享,因此需要保护机制
pthread_mutex_t clifd_mutex;
pthread_cond_t clifd_cond;
  • main函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#include	"unpthread.h"
#include "pthread08.h"

static int nthreads;
pthread_mutex_t clifd_mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t clifd_cond = PTHREAD_COND_INITIALIZER;

int main(int argc, char **argv) {
int i, listenfd, connfd;
void sig_int(int), thread_make(int);
socklen_t addrlen, clilen;
struct sockaddr *cliaddr;

if (argc == 3)
listenfd = Tcp_listen(NULL, argv[1], &addrlen);
else if (argc == 4)
listenfd = Tcp_listen(argv[1], argv[2], &addrlen);
else
err_quit("usage: serv08 [ <host> ] <port#> <#threads>");
cliaddr = Malloc(addrlen);

nthreads = atoi(argv[argc-1]);
tptr = Calloc(nthreads, sizeof(Thread));
iget = iput = 0;

/* create all the threads */
for (i = 0; i < nthreads; i++)
thread_make(i); /* only main thread returns */

Signal(SIGINT, sig_int);

for ( ; ; ) {
clilen = addrlen;
connfd = Accept(listenfd, cliaddr, &clilen);
Pthread_mutex_lock(&clifd_mutex); // 上锁
// 将connfd存入clifd的iput位置
clifd[iput] = connfd;
if (++iput == MAXNCLI) // 环形队列
iput = 0;
if (iput == iget) // 头尾相撞,报错
err_quit("iput = iget = %d", iput);
Pthread_cond_signal(&clifd_cond); // 唤醒至少一个阻塞在条件变量上的线程
Pthread_mutex_unlock(&clifd_mutex); // 解锁
}
}
  • thread_makethread_main函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include	"unpthread.h"
#include "pthread08.h"

void thread_make(int i) {
void *thread_main(void *);

Pthread_create(&tptr[i].thread_tid, NULL, &thread_main, (void *) i);
return; /* main thread returns */
}

void *thread_main(void *arg) {
int connfd;
void web_child(int);

printf("thread %d starting\n", (int) arg);
for ( ; ; ) {
Pthread_mutex_lock(&clifd_mutex); // 加锁
while (iget == iput) // 没有可用的已连接套接字
Pthread_cond_wait(&clifd_cond, &clifd_mutex); // 阻塞等待并释放锁
connfd = clifd[iget]; /* connected socket to service */
if (++iget == MAXNCLI)
iget = 0;
Pthread_mutex_unlock(&clifd_mutex); // 解锁
tptr[(int) arg].thread_count++;

web_child(connfd); /* process request */
Close(connfd);
}
}

21.13 小结

  • 系统负载较轻时,每来一个客户请求现场派生一个子进程为之服务的传统并发服务器程序模型就足够了。这个模型甚至可以与inetd结合使用,也就是inetd处理每个连接的接受。我们的其他意见是就重负荷运行的服务器而言的,譬如Web服务器。
  • 相比传统的每个客户fork一次设计范式,预先创建一个子进程池或一个线程池的设计范式能够把进程控制CPU时间降低10倍或以上。编写这些范式的程序并不复杂,不过需超越本章所给例子的是:监视闲置子进程个数,随着所服务客户数的动态变化而增加或减少这个数目。
  • 某些实现允许多个子进程或线程阻塞在同一个accept调用中,另一些实现却要求包绕accept调用安置某种类型的锁加以保护。文件上锁或Pthread互斥锁上锁都可以使用。
  • 让所有子进程或线程自行调用accept通常比让父进程或主线程独自调用accept并把描述符传递给子进程或线程来得简单而快速。
  • 由于潜在select冲突的原因,让所有子进程或线程阻塞在同一个accept调用中比让它们阻塞在同一个select调用中更可取。
  • 使用线程通常远快于使用进程。不过选择每个客户一个子进程还是每个客户一个线程取决于操作系统提供什么支持,还可能取决于为服务每个客户需激活其他什么程序(若有其他程序需激活的话)。举例来说,如果accept客户连接的服务器调用fork和exec (譬如说inetd超级守护进程),那么fork一个单线程的进程可能快于fork一个多线程的进程。