多进程: 并发服务器


使用TCP客户/服务器程序实现echo服务:

  1. 客户端从标准输入读入一行文本,并发送给服务器。
  2. 服务器从网络输入中读入这行文本,并返回给客户端。
  3. 客户端从网络输入中读入这行文本,并显示在标准输出上。

如图描述了这个客户/服务器:

echo.png

echo服务器程序

代码如下:

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
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
#include <stdio.h>                                                        
#include <errno.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <string.h>
#include <arpa/inet.h>
#include <unistd.h>

#include "error.h"

#define MAXLINE 4096
#define SERV_PORT 9987
#define SA struct sockaddr
#define LISTENQ 1024

static int Socket(int family, int type, int protocol) {
int n;

if((n = socket(family, type, protocol)) < 0) {
err_sys("socket error");
}

return n;
}

static void Bind(int fd, struct sockaddr *sa, socklen_t salen) {
if(bind(fd, sa, salen) < 0) {
err_sys("bind error");
}
}

static void Listen(int fd, int backlog) {
char *ptr;

if((ptr = getenv("LISTENQ")) != NULL) {
backlog = atoi(ptr);
}

if(listen(fd, backlog) < 0) {
err_sys("listen error");
}
}

static int Accept(int fd, struct sockaddr *sa, socklen_t *salenptr) {
int n;

while((n = accept(fd, sa, salenptr)) < 0) {
if(errno != EINTR && errno != ECONNABORTED) {
err_sys("accept error");
}
}

return n;
}

static void Close(int fd) {
if(close(fd) == -1) {
err_sys("close error");
}
}

static int writen(int fd, void *vptr, size_t nbytes) {
const char *ptr = vptr;
size_t left = nbytes;
int nwritten;

while(left > 0) {
if((nwritten = write(fd, vptr, left)) < 0) {
return -1;
}

ptr += nwritten;
left -= nwritten;
}

return nbytes;
}

static void Writen(int fd, void *vptr, size_t nbytes) {
if(writen(fd, vptr, nbytes) != nbytes) {
err_sys("write error");
}
}

static void str_echo(int fd) {
char buf[MAXLINE];
int n;

while((n = read(fd, buf, MAXLINE))) {
if(n < 0) {
if(errno == EINTR) {
continue;
} else {
err_sys("str_echo: read error");
}
} else {
Writen(fd, buf, n);
}
}
}

int main(int argc, char *argv[]) {
struct sockaddr_in servaddr;
int listenfd;
int connfd;
pid_t pid;

listenfd = Socket(AF_INET, SOCK_STREAM, 0);

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

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

Listen(listenfd, LISTENQ);

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

pid = fork();
if(pid == 0) {
Close(listenfd);
str_echo(connfd);
exit(0);
} else if(pid > 0) {
Close(connfd);
} else {
err_sys("fork error");
}
}

return 0;
}

main函数

  1. 调用socket函数创建一个套接字,IP地址为INADDR_ANY、端口号为9987,调用bind函数绑定该套接字,listen函数把该套接字转换成一个监听套接字。
  2. 服务器阻塞于accept调用,等待客户连接的完成。accept要处理系统调用被中断和accept返回时,连接被终止的情况。
  3. fork为每个客户派生一个处理它们的子进程。子进程关闭监听套接字,父进程关闭已连接套接字。子进程接着调用str_echo处理客户端。

描述符引用计数

每个文件或套接字都有一个引用计数,父进程关闭已连接套接字只是导致相应描述符的引用计数值减1。既然引用计数值仍大于0,这个close调用并不引发TCP的四分组连接终止序列。TCP的四分组连接终止序列发生在子进程调用str_echo返回后,调用exit函数退出时。

如果我们确定想在某个TCP连接上发送一个FIN,那么可以改用shutdown函数以代替close

如果父进程符对每个由accept返回的已连接套接字都不调用close,首先,父进程最终将耗尽可用描述符,因为任何进程在任何时刻可拥有的打开着的描述符通常是有限制的。不过更重要的是,没有一个客户连接会被终止。当子进程关闭已连接套接字时,它的引用计数值将由2递减为1且保持为1。因为父进程永不关闭任何已连接套接字。这将妨碍TCP连接终止序列的发送,导致连接一直打开着。

str_echo函数

read函数从套接字读入数据,writen函数把其中内容返回给客户端。如果客户关闭连接,那么接收到客户的 FIN 将导致服务器子进程的read函数返回0,这将导致str_echo函数返回,从而终止子进程。read的时候要处理系统调用被中断的情况。

echo客户端程序

代码如下:

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
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
#include <stdio.h> 
#include <stdlib.h>
#include <errno.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <string.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <signal.h>

#include "error.h"

#define MAXLINE 4096
#define SERV_PORT 9987
#define SA struct sockaddr

static int Socket(int family, int type, int protocol) {
int n;

if((n = socket(family, type, protocol)) < 0) {
err_sys("socket error");
}

return n;
}

static void Inet_pton(int family, const char *strptr, void *addrptr) {
if(inet_pton(family, strptr, addrptr) <=0) {
err_sys("inet_pton error for %s", strptr);
}
}

static void Connect(int fd, struct sockaddr *sa, socklen_t salen) {
if(connect(fd, sa, salen) < 0) {
err_sys("connect error");
}
}

static const char* Fgets(char *ptr, int n, FILE *stream) {
char *ret_ptr;

if((ret_ptr = fgets(ptr, n, stream)) == NULL && ferror(stream)) {
err_sys("fgets error");
}

return ret_ptr;
}

static int writen(int fd, void *vptr, size_t nbytes) {
const char *ptr = vptr;
size_t left = nbytes;
int nwritten;

while(left > 0) {
if((nwritten = write(fd, vptr, left)) < 0) {
return -1;
}

ptr += nwritten;
left -= nwritten;
}

return nbytes;
}

static void Writen(int fd, void *vptr, size_t nbytes) {
if(writen(fd, vptr, nbytes) != nbytes) {
err_sys("write error");
}
}

static int readline(int fd, void *vptr, size_t maxlen) {
int i;
char *ptr = vptr;
int n;
char c;

for(i = 0; i < maxlen - 1; ++i) {
if((n = read(fd, &c, 1)) == 1) {
*ptr++ = c;
if(c == '\n') {
break; /* newline */
}
} else if(n == 0) {
if(i == 0) {
return 0; /* EOF, no data read */
} else {
break; /* EOF. some data was read */
}
} else {
if(errno == EINTR) {
continue;
} else {
return -1; /* read error */
}
}
}

*ptr = '\0';

return i + 1;
}

static int Readline(int fd, void *vptr, size_t maxlen) {
int n;

if((n = readline(fd, vptr, maxlen)) < 0) {
err_sys("read error");
}

return n;
}

static void Fputs(const char *ptr, FILE *stream) {
if(fputs(ptr, stream) == EOF) {
err_sys("fputs error");
}
}

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

while((Fgets(sendline, MAXLINE, fp)) != NULL) {
Writen(sockfd, sendline, strlen(sendline));
if(Readline(sockfd, recvline, MAXLINE) == 0) {
err_quit("server terminated permaturely");
}
Fputs(recvline, stdout);
}
}

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

if(argc != 2) {
err_quit("%s <IPAddress>", argv[0]);
}

sockfd = Socket(AF_INET, SOCK_STREAM, 0);

bzero(&servaddr, 0);
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(SERV_PORT);
Inet_pton(AF_INET, argv[1], &servaddr.sin_addr);

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

str_cli(stdin, sockfd);

return 0;
}

main函数

  1. 调用socket函数创建一个套接字,IP地址由命令行传入、端口号为9987。
  2. 调用connect建立与服务器的连接。调用str_cli函数从标准输入读入一行文本,写到服务器,服务器返回该行数据,并把该行数据写到标准输出上。

str_cli函数

  1. fgets读入一行文本,writen把该行发送给服务器
  2. readline从服务器读入返回的数据,fputs把它写到标准输出。readline函数每次读取的是一个字节,性能是比较低的,可以加个缓冲区。read的时候返回0表示服务器已经终止。

正常启动

在后台启动服务器

[heql@ubuntu socket]$ ./server &
[1] 3359

服务器启动后,它调用socketbindlistenaccept,并阻塞与accept调用。(我们还没有启动客户)。在启动客户之前,我们运行netstat程序来检查服务器监听套接字的状态。

[heql@ubuntu socket]$ netstat -a | grep 9987
tcp        0      0 *:9987                  *:*                     LISTEN

有一个套接字处于LISTEN状态,它有通配的本地IP地址,本地端口号为9877。netstat用星号“ * ”来表示一个为0的IP地址(IPADDR_ANY,通配地址)或为0的端口号。

在同一个主机上启动客户端程序

[heql@ubuntu socket]$ ./client 127.0.0.1
  1. 客户调用str_cli函数,该函数将阻塞于fgets调用,因为我们还未曾键入过一行文件。
  2. 当服务器中的accept返回时,服务器调用fork,再由子进程调用str_echo。该函数调用read,而read在等待客户输入。
  3. 另一方面,服务器父进程再次调用accept并阻塞,等待下一个客户连接。

客户收到三路握手的第二个分节时,connect返回,而服务器要直到接收到三路握手的第三个分节才返回,即在connect返回之后再过一半RTT才返回。

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

[heql@ubuntu socket]$ netstat -a | grep 9987
tcp        0      0 *:9987                  *:*                     LISTEN     
tcp        0      0 localhost:46704         localhost:9987          ESTABLISHED
tcp        0      0 localhost:9987          localhost:46704         ESTABLISHED    

第一个 ESTABLISHED 对应于客户进程的套接字,因为它的端口号是46704,第二个 ESTABLISHED 对应于服务器子进程的套接字,因为它的端口号是9987。

正常终止

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

[heql@ubuntu socket]$ ./client 127.0.0.1
hello, world
hello, world        这一行是服务器返回来的
^D                    <Ctrl+D>终端EOF字符

键入一行,服务器返回后,接着键入终端EOF字符已终止客户。此时如果立即执行netstat命令,可以看到如下结果:

[heql@ubuntu socket]$ netstat -a | grep 9987
tcp        0      0 *:9987                  *:*                     LISTEN     
tcp        0      0 localhost:46710         localhost:9987          TIME_WAIT

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

正常终止客户和服务器的步骤:

  1. 当键入EOF字符时,fgets返回一个空指针,于是str_cli函数返回
  2. str_cli返回到客户的main函数,main终止。
  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信号。这一点在本例中发生了,但是没有在代码中捕获该信号,而该信号的默认行为是被忽略。既然父进程未加处理,子进程于是处于僵死状态。我们可以使用ps命令验证这一点。

    [heql@ubuntu socket]$ ps aux | grep server
    heql       3359  0.0  0.0   4216   636 pts/6    S    17:07   0:00 ./server
    heql       3377  0.0  0.0      0     0 pts/6    Z    17:12   0:00 [server] <defunct>
    

POSIX信号处理

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

信号可以:

  • 由一个进程发给另一个进程(或自身)
  • 由内核发给进程。

SIGCHLD信号就是由内核在任何一个进程终止时发给它的父进程的一个信号。

信号处置,有三种选择:

  1. 我们可以提供一个函数,只要有特定信号发生它就被调用。这样的函数称为 信号处理函数 ,这种行为称为捕获信号。有两个信号不能被捕获,他们是SIGKILLSIGSTOP。对于大多数信号来说,调用sigaction函数并指定信号发生时所调用的函数就是捕获信号所需做的全部工作。但是SIGIOSIGPOLL、和SIGUSR这些个别信号还要求捕获它们的进程做些额外工作。
  2. 可以把某个信号的处置设定为SIG_IGN来忽略它。SIGKILLSIGSTOP这两个信号不能被忽略。
  3. 可以把某个信号的处置设定为SIG_DEF来启用它的默认设置。默认设置通常是在收到信号后终止进程,其中某些信号还在当前工作目录产生一个进程的核心映像(也称为内存映像)。另有个别的默认处置是忽略,SIGCHLDSIGUSR

POSIX信号语义

  • 一旦安装了信号处理函数,它便一直安装着。
  • 在一个信号处理函数运行期间,正被递交的信号时阻塞的(这里的阻塞某个信号或某个信号集,防止它们在阻塞期间递交)。而且,安装处理函数时在传递给sigaction函数的sig_mask信号集中指定的额外信号也被阻塞。我们将sig_mask置为空集,意味着除了被捕获的信号外,没有额外信号被阻塞。
  • 如果一个信号在被阻塞期间产生了一次或多次,那么该信号被解阻塞之后通常只递交一次,也就是说Unix信号默认是不排队的。
  • sigprocmask函数选择性地阻塞或解阻塞一组信号是可能的。这使得我们可以做到在一段临界区代码执行期间,防止捕获某些信号,以此保护这段代码。

处理SIGCHLD信号

设置僵死状态的目的是维护子进程的信息,以便父进程在以后某个时候获取。这些信息包括子进程的进程ID、终止状态以及资源利用信息(CPU时间、内存使用量等等)。如果一个进程终止,而该进程有子进程处于僵死状态,那么它的所有僵死子进程的父进程ID将被设置为1(init进程)。继承这些子进程的init进程将清理它们(也就是说init进程将wait它们,从而去除它们的僵死状态)

处理僵死进程

我们显然不愿意留存僵死进程。它们占用内核中的空间,最终可能导致我们耗尽进程资源。无论何时我们fork子进程都得wait它们,以防它们变成僵死进程。为此我们建立一个捕获SIGCHLD信号的信号处理函数,在函数体中我们调用wait

1
2
3
4
5
6
7
8
void sig_chld(int signo) {
pid_t pid;
int stat;

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

在信号处理函数中调用诸如printf这样的标准I/O函数是不适合的,在这里调用printf只是作为查看子进程何时终止的诊断手段。

[heql@ubuntu socket]$ ./server &
[heql@ubuntu socket]$ ./client 127.0.0.1
hi there
hi there
^D
[heql@ubuntu socket]$ child 5254 terminated
  1. 键入 EOF 字符来终止客户。客户TCP发送一个 FIN 给服务器,服务器响应一个 ACK
  2. 收到客户的 FIN 导致服务器递送一个 EOF 给子进程阻塞中的readline,从而子进程终止。
  3. SIGCHLD信号递交时,父进程阻塞于accept调用。sig_chld函数(信号处理函数)执行,其wait调用取到子进程的PID和终止状态,随后printf调用,最后返回。
  4. 既然该信号是在父进程阻塞于慢系统调用(accept)时由父进程捕获的,内核就会使accept返回一个EINTR错误(被中断的系统调用)。而父进程如果不处理该错误,则会终止服务器程序。如下:

    [heql@ubuntu socket]$ ./server &
    [heql@ubuntu socket]$ ./client 127.0.0.1
    hi there
    hi there
    ^D
    [heql@ubuntu socket]$ child 5254 terminated
    accept error: Interrupted system call
    

在编写捕获信号的网络程序时,必须认清被中断的系统调用且处理它们。如果系统函数库的signal函数没有设置的SA_RESTART标志,则不会使内核自动重启被中断的系统调用。如果系统自动重启被中断的系统调用,那么内核将重启被中断的系统调用,那么accept就不会返回错误。

处理被中断的系统调用

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

1
2
3
4
5
6
7
while(connfd = accept(listenfd, (SA *) &cliaddr, &clilen)) < 0) {
if(error == EINTR)
continue;
else
err_sys("accept error");
}
}

这段代码所做的事情就是自己重启被中断的系统调用。对于accept以及诸如readwriteselectopen之类函数来说,这是适合的。不过有一个函数我们不能重启:connect。如果该函数返回 EINTR ,我们就不能再次调用它,否则将立即返回一个错误。当connect被一个捕获的信号中断而且不自动重启时。

wait 和 waitpid函数

1
2
3
#include <sys/wait.h>
pid_t wait(int *statloc);
pid_t waitpid(pid_t pid, int *statloc, int options);

函数waitwaitpid均返回两个值:已终止子进程的PID,以及通过statloc指针返回的子进程终止状态(一个整数),可以通过该值辨别子进程是正常终止、由某个信号杀死还是仅仅由作业控制停止。

如果调用wait的进程没有已终止的子进程,不过有一个或多个子进程仍在执行,那么wait将阻塞到现有子进程第一个终止为止。

waitpid函数就等待哪个进程以及是否阻塞给了我们更多的控制。首先,pid参数允许我们指定等待的进程ID,值-1表示等待第一个终止的子进程。其次,options参数允许我们指定附加选项。最常用的选项是WNOHANG,它告知内核在没有已终止子进程时不要阻塞。

函数wait 和 waitpid的区别

把上面的TCP客户端程序修改为建立5个与服务器的连接,随后在调用str_cli函数时仅用第一个连接(sockfd[0])。建立多个连接的目的是从并发服务器上派生多个子进程。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int main(int argc, char *argv[]) {
struct sockaddr_in servaddr;
int sockfd[5];
int i;

if(argc != 2) {
err_quit("%s <IPAddress>", argv[0]);
}

for(i = 0; i < 5; ++i) {
sockfd[i] = Socket(AF_INET, SOCK_STREAM, 0);

bzero(&servaddr, 0);
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(SERV_PORT);
Inet_pton(AF_INET, argv[1], &servaddr.sin_addr);

Connect(sockfd[i], (SA *) &servaddr, sizeof(servaddr));
}

str_cli(stdin, sockfd[0]);

return 0;
}

当客户终止时,所有打开的描述符由内核自动关闭,且所有5个连接基本在同一时刻终止。这就引发了5个 FIN ,每个连接一个,它们反过来使服务器的5个进程基本在同一时刻终止。这又导致差不多在同一时刻有5个SIGCHLD信号递交给父进程

运行服务器的程序:

[heql@ubuntu socket]$ ./server

运行修改的客户端程序:

[heql@ubuntu socket]$ ./client 127.0.0.1
hi there
hi there
^D

服务器的输出:

[heql@ubuntu socket]$ ./server
child 4771 terminated
child 4770 terminated
child 4772 terminated
child 4773 terminated

只有4个进程有printf输出,还有一个子进程任仍然处于僵死进程存在,查看进程状态:

[heql@ubuntu socket]$ ps aux | grep server
heql       4768  0.0  0.0   4220   640 pts/6    S+   20:15   0:00 ./server
heql       4774  0.0  0.0      0     0 pts/6    Z+   20:15   0:00 [server] <defunct>

建立一个信号处理函数并在其中调用wait并不足以防止出现僵死进程。本问题在于:所有5个信号都在信号处理函数执行之前产生,而信号处理函数只执行一次,因为Unix信号一般是不排队的。更严重的是,本问题是不确定的。客户与服务器在同一个主机上,信号处理函数执行1次,留下4个僵死进程。但是如果我们在不同的主机上运行客户和服务器,那么信号处理函数一般执行2次:一次是第一个产生的信号引起的,由于另外4个信号在信号处理函数第一次执行时发生,因此该处理函数仅仅再被调用一次,从而留下3个僵死进程。不过有的时候,依赖于 FIN 到达服务器主机的时机,信号处理函数可能执行3次甚至4次。

正确的解决办法是调用waitpid而不是wait,必须指定WNOHANG选项,它告知waitpid在有尚未终止的子进程在运行时不要阻塞。

1
2
3
4
5
6
7
8
9
static void sig_chld(int signo) {
pid_t pid;
int stat;

while((pid = waitpid(-1, &stat, WNOHANG)) > 0) {
printf("child %d terminated\n", pid);
}
return ;
}

accept返回前连接中止

类似于被中断系统调用的例子,另有一种情形也能够导致accept返回一个非致命的错误,在这种情况下,只需要再次调用accept。在较繁忙的服务器(典型的是较忙的Web服务器)上已出现过。

这里,三路握手完成从而连接建立之后,客户TCP却发送了一个RST(复位)。在服务器端开来,就在该连接已由TCP排队,等着服务器进程调用accept的时候 RST 到达。稍后,服务器进程调用accept

模拟这种情形的一个简单方法就是:启动服务器,让它调用socketbindlisten,然后再调用accept之前睡眠一小段时间。在服务器进程睡眠时,启动客户,让它调用socketconnect。一旦connect返回,就设置SO_LINGER套接字选项以产生这个 RST ,然后终止。

但是,如何处理这种中止的连接依赖于不同的实现。源自Berkeley的完全在内核中处理中止的连接。服务器进程根本看不到。然而大多数 SVR4实现返回一个错误给服务器进程,作为accept的返回结果,不过错误本身取决于实现。这些 SVR4 实现返回一个 EPROTO (“protocol error”)errno值,而 POSIX 指出返回的errno值必须是ECONNABORTED(“software caused connection abort”, 软件引起的连接中止)。

服务器进程终止

启动我们的客户/服务器对,然后杀死服务器子进程。这是在模拟服务器进程崩溃的情形,我们可以查看客户将发生什么。(必须小心区别即将讨论的服务器 进程 崩溃与服务器 主机 崩溃)所发生的步骤如下所述:

  1. 我们在同一主机上启动服务器和客户,并在客户上键入一行文件,以验证一切正常。正常情况下该行文本由服务器子进程返回给客户端。
  2. 找到服务器子进程的进程ID,并执行kill命令杀死它。作为进程终止处理的部分工作,子进程中所有打开着的描述符都被关闭。这就导致向客户发送一个 FIN ,而客户TCP则响应以一个 ACK 。这就是TCP连接终止工作的前半部分。
  3. SIGCHLD 信号被发送给服务器父进程,并得到正确处理。
  4. 客户上没有发生任何特殊之事。客户TCP接收来自服务器TCP的 FIN 并响应以一个 ACK ,然而问题是客户进程阻塞在fgets调用上,等待从终端接收一行文本。
  5. 此时,在另外一个窗口上运行netstat命令,以观察套接字的状态,可以看到TCP连接终止序列的前半部分已经完成。

    [heql@ubuntu socket]$ netstat -a | grep 9987
    tcp        0      0 *:9987                  *:*                     LISTEN     
    tcp        1      0 localhost:46978         localhost:9987          CLOSE_WAIT 
    tcp        0      0 localhost:9987          localhost:46978         FIN_WAIT2 
    
  6. 在客户端上再键入一行文本。

    another line
    str_cli: server terminated prematurely
    

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

当服务器TCP接收到来自客户的数据时,既然先前打开那个套接字的进程已经终止,于是响应以一个RST。

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

本例子的问题在于:在 FIN 到达套接字时,客户正阻塞在fgets调用上。客户实际上在应对两个描述符——套接字和用户输入,它不能单纯阻塞在这两个源中某个特定源的输入上,而是应该阻塞在其中任何一个源的输入上。事实上这正是selectpoll这两个函数的目的之一,一旦杀死服务器子进程,客户就会立即被告知已收到 FIN

SIGPIPE信号

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

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

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

第一次写操作引发 RST,第二次写引发SIGPIPE信号。写一个已接收了FIN的套接字不成问题,但是写一个已接收了 RST 的套接字则是一个错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void str_cli(FILE *fp, int sockfd) {
char sendline[MAXLINE];
char recvline[MAXLINE];

while(Fgets(sendline, MAXLINE, fp) != NULL) {
Writen(sockfd, sendline, 1);
sleep(1);
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

[heql@ubuntu socket]$ ./client 127.0.0.1
hi there
hi there    被服务器回射回来
            在这杀死服务器子进程
bye         然后键入这行文本
Broken pipe 本行由shell显示

启动客户端,键入一行文本,看到它被正确返回后,在服务器主机上终止服务器子进程。接着键入另一行文本(“ bye ”),结果是没有任何返回,而shell告诉我们客户进程因为SIGPIPE信号而死亡了。当前台进程未曾执行内存内容core dumping就死亡时,有些shell不显示任何信息。处理SIGPIPE的建议方法取决于它发生时应用进程想做什么。如果没有特殊的事情要做,那么将信号处理方法直接设置为SIG_IGN,并假设后续的输出操作将捕获EPIPE错误并终止。如果信号出现时需采取特殊措施(可能需在日志文件中登记),那么就必须捕获该信号,以便在信号处理函数中执行所有期望的动作。但是必须意识到,如果使用了多个套接字,该信号的递交无法告诉我们是哪个套接字出的错。如果我们确实需要知道是哪个write出了错,那么必须要么不理会该信号,要么从信号处理函数返回后再处理来自writeEPIPE

服务器主机崩溃

为了模拟这种情形,我们必须在不同的主机上运行客户和服务器。我们先启动服务器,再启动客户,接着在客户上键入一行文本以确认连接工作正常,然后从网络上断开服务器主机,并在客户上键入另一行文本。这样同时也模拟了客户发送数据时服务器主机不可达的情形(即建立连接后某些中间路由器不工作)。

步骤如下所述:

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

尽管我们的客户最终还是会发现对端主机已崩溃或不可达,不多超时的时间是由内核决定的,可能会等待的时间比较常,可以在readline调用设置一个超时的时间,或用心跳检测程序。

服务器主机崩溃后重启

在这种情形中,我们先在客户与服务器之间建立连接,然后假设服务器主机崩溃并重启。模拟这种情形的最简单方法就是:先建立连接,再从网络上断开服务器主机,将它关机后再重新启动,最后把它从新连接到网络中。我们不想客户知道服务器的关机。

如果在服务器主机崩溃时客户不主动给服务器发送数据,那么客户将不会知道服务器主机已经崩溃。(这里假设我们没有使用SO_KEEPALIVE套接字选项)。所发生的步骤如下所述:

  1. 我们启动服务器和客户,并在客户键入一行文本以确认连接已经建立。
  2. 服务器主机崩溃并重启
  3. 在客户上键入一行文本,它将作为一个TCP数据分节发送到服务器主机。
  4. 当服务器主机崩溃后重启时,它的TCP丢失了崩溃前的所有连接信息,因此服务器TCP对于所收到的来自客户的数据分节响应以一个 RST
  5. 当客户TCP收到该 RST 时,客户正阻塞于readline调用,导致该调用返回 ECONNRESET 错误。

如果对客户而言检测服务器主机崩溃与否很重要,即使客户不主动发送数据也要检测出来,就需要采用其他某种技术(诸如SO_KEEPALIVE套接字选项或某些客户/服务器心跳函数)。

服务器主机关机

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