使用TCP客户/服务器程序实现echo服务:
- 客户端从标准输入读入一行文本,并发送给服务器。
- 服务器从网络输入中读入这行文本,并返回给客户端。
- 客户端从网络输入中读入这行文本,并显示在标准输出上。
如图描述了这个客户/服务器:
echo服务器程序
代码如下:
1 |
|
main函数
- 调用
socket
函数创建一个套接字,IP地址为INADDR_ANY、端口号为9987,调用bind
函数绑定该套接字,listen
函数把该套接字转换成一个监听套接字。 - 服务器阻塞于
accept
调用,等待客户连接的完成。accept
要处理系统调用被中断和accept
返回时,连接被终止的情况。 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 |
|
main函数
- 调用
socket
函数创建一个套接字,IP地址由命令行传入、端口号为9987。 - 调用
connect
建立与服务器的连接。调用str_cli
函数从标准输入读入一行文本,写到服务器,服务器返回该行数据,并把该行数据写到标准输出上。
str_cli函数
fgets
读入一行文本,writen
把该行发送给服务器readline
从服务器读入返回的数据,fputs
把它写到标准输出。readline
函数每次读取的是一个字节,性能是比较低的,可以加个缓冲区。read
的时候返回0表示服务器已经终止。
正常启动
在后台启动服务器
[heql@ubuntu socket]$ ./server &
[1] 3359
服务器启动后,它调用socket
、bind
、listen
和accept
,并阻塞与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
- 客户调用
str_cli
函数,该函数将阻塞于fgets
调用,因为我们还未曾键入过一行文件。 - 当服务器中的
accept
返回时,服务器调用fork
,再由子进程调用str_echo
。该函数调用read
,而read
在等待客户输入。 - 另一方面,服务器父进程再次调用
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
状态,而监听服务器仍在等待另一个客户连接。
正常终止客户和服务器的步骤:
- 当键入
EOF
字符时,fgets
返回一个空指针,于是str_cli
函数返回 - 当
str_cli
返回到客户的main
函数,main
终止。 - 进程终止处理的部分工作是关闭所有打开的描述符,因此客户打开的套接字由内核关闭。这导致客户TCP发送一个 FIN 给服务器,服务器TCP则以 ACK 响应,这就是TCP连接终止序列的前半部分。至此,服务器套接字处于 CLOSE_WAIT 状态,客户套接字则处于 FIN_WAIT_2 状态。
- 当服务器TCP接收 FIN 时,服务器子进程阻塞于
readline
调用,于是readline
返回0。这导致str_echo
函数返回服务器子进程的main
函数。 - 服务器子进程通过调用
exit
来终止。 - 服务器子进程中打开的所有描述符随之关闭。由子进程来关闭已连接套接字会引发TCP连接终止序列的最后两个分节:一个从服务器到客户的 FIN 和一个客户到服务器的 ACK 。至此,连接完全终止,客户套接字进入 TIME_WAIT 状态。
进程终止处理的另一部分内容是:在服务器子进程终止时,给父进程发送一个
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
信号就是由内核在任何一个进程终止时发给它的父进程的一个信号。
信号处置,有三种选择:
- 我们可以提供一个函数,只要有特定信号发生它就被调用。这样的函数称为 信号处理函数 ,这种行为称为捕获信号。有两个信号不能被捕获,他们是
SIGKILL
和SIGSTOP
。对于大多数信号来说,调用sigaction
函数并指定信号发生时所调用的函数就是捕获信号所需做的全部工作。但是SIGIO
、SIGPOLL
、和SIGUSR
这些个别信号还要求捕获它们的进程做些额外工作。 - 可以把某个信号的处置设定为
SIG_IGN
来忽略它。SIGKILL
和SIGSTOP
这两个信号不能被忽略。 - 可以把某个信号的处置设定为
SIG_DEF
来启用它的默认设置。默认设置通常是在收到信号后终止进程,其中某些信号还在当前工作目录产生一个进程的核心映像(也称为内存映像)。另有个别的默认处置是忽略,SIGCHLD
和SIGUSR
。
POSIX信号语义
- 一旦安装了信号处理函数,它便一直安装着。
- 在一个信号处理函数运行期间,正被递交的信号时阻塞的(这里的阻塞某个信号或某个信号集,防止它们在阻塞期间递交)。而且,安装处理函数时在传递给
sigaction
函数的sig_mask
信号集中指定的额外信号也被阻塞。我们将sig_mask
置为空集,意味着除了被捕获的信号外,没有额外信号被阻塞。 - 如果一个信号在被阻塞期间产生了一次或多次,那么该信号被解阻塞之后通常只递交一次,也就是说Unix信号默认是不排队的。
sigprocmask
函数选择性地阻塞或解阻塞一组信号是可能的。这使得我们可以做到在一段临界区代码执行期间,防止捕获某些信号,以此保护这段代码。
处理SIGCHLD信号
设置僵死状态的目的是维护子进程的信息,以便父进程在以后某个时候获取。这些信息包括子进程的进程ID、终止状态以及资源利用信息(CPU时间、内存使用量等等)。如果一个进程终止,而该进程有子进程处于僵死状态,那么它的所有僵死子进程的父进程ID将被设置为1(init进程)。继承这些子进程的init进程将清理它们(也就是说init进程将wait它们,从而去除它们的僵死状态)
处理僵死进程
我们显然不愿意留存僵死进程。它们占用内核中的空间,最终可能导致我们耗尽进程资源。无论何时我们fork
子进程都得wait
它们,以防它们变成僵死进程。为此我们建立一个捕获SIGCHLD
信号的信号处理函数,在函数体中我们调用wait
。
1 | void sig_chld(int signo) { |
在信号处理函数中调用诸如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
- 键入 EOF 字符来终止客户。客户TCP发送一个 FIN 给服务器,服务器响应一个 ACK 。
- 收到客户的 FIN 导致服务器递送一个 EOF 给子进程阻塞中的
readline
,从而子进程终止。 - 当
SIGCHLD
信号递交时,父进程阻塞于accept
调用。sig_chld
函数(信号处理函数)执行,其wait
调用取到子进程的PID和终止状态,随后printf
调用,最后返回。 既然该信号是在父进程阻塞于慢系统调用(
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 | while(connfd = accept(listenfd, (SA *) &cliaddr, &clilen)) < 0) { |
这段代码所做的事情就是自己重启被中断的系统调用。对于accept
以及诸如read
、write
、select
和open
之类函数来说,这是适合的。不过有一个函数我们不能重启:connect
。如果该函数返回 EINTR ,我们就不能再次调用它,否则将立即返回一个错误。当connect
被一个捕获的信号中断而且不自动重启时。
wait 和 waitpid函数
1 |
|
函数wait
和waitpid
均返回两个值:已终止子进程的PID,以及通过statloc
指针返回的子进程终止状态(一个整数),可以通过该值辨别子进程是正常终止、由某个信号杀死还是仅仅由作业控制停止。
如果调用wait
的进程没有已终止的子进程,不过有一个或多个子进程仍在执行,那么wait
将阻塞到现有子进程第一个终止为止。
waitpid
函数就等待哪个进程以及是否阻塞给了我们更多的控制。首先,pid
参数允许我们指定等待的进程ID,值-1表示等待第一个终止的子进程。其次,options
参数允许我们指定附加选项。最常用的选项是WNOHANG
,它告知内核在没有已终止子进程时不要阻塞。
函数wait 和 waitpid的区别
把上面的TCP客户端程序修改为建立5个与服务器的连接,随后在调用str_cli
函数时仅用第一个连接(sockfd[0])。建立多个连接的目的是从并发服务器上派生多个子进程。代码如下:
1 | int main(int argc, char *argv[]) { |
当客户终止时,所有打开的描述符由内核自动关闭,且所有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 | static void sig_chld(int signo) { |
accept返回前连接中止
类似于被中断系统调用的例子,另有一种情形也能够导致accept
返回一个非致命的错误,在这种情况下,只需要再次调用accept
。在较繁忙的服务器(典型的是较忙的Web服务器)上已出现过。
这里,三路握手完成从而连接建立之后,客户TCP却发送了一个RST(复位)。在服务器端开来,就在该连接已由TCP排队,等着服务器进程调用accept
的时候 RST 到达。稍后,服务器进程调用accept
。
模拟这种情形的一个简单方法就是:启动服务器,让它调用
socket
、bind
和listen
,然后再调用accept
之前睡眠一小段时间。在服务器进程睡眠时,启动客户,让它调用socket
和connect
。一旦connect
返回,就设置SO_LINGER套接字选项以产生这个 RST ,然后终止。
但是,如何处理这种中止的连接依赖于不同的实现。源自Berkeley的完全在内核中处理中止的连接。服务器进程根本看不到。然而大多数 SVR4实现返回一个错误给服务器进程,作为accept
的返回结果,不过错误本身取决于实现。这些 SVR4 实现返回一个 EPROTO (“protocol error”)errno
值,而 POSIX 指出返回的errno
值必须是ECONNABORTED(“software caused connection abort”, 软件引起的连接中止)。
服务器进程终止
启动我们的客户/服务器对,然后杀死服务器子进程。这是在模拟服务器进程崩溃的情形,我们可以查看客户将发生什么。(必须小心区别即将讨论的服务器 进程 崩溃与服务器 主机 崩溃)所发生的步骤如下所述:
- 我们在同一主机上启动服务器和客户,并在客户上键入一行文件,以验证一切正常。正常情况下该行文本由服务器子进程返回给客户端。
- 找到服务器子进程的进程ID,并执行
kill
命令杀死它。作为进程终止处理的部分工作,子进程中所有打开着的描述符都被关闭。这就导致向客户发送一个 FIN ,而客户TCP则响应以一个 ACK 。这就是TCP连接终止工作的前半部分。 - SIGCHLD 信号被发送给服务器父进程,并得到正确处理。
- 客户上没有发生任何特殊之事。客户TCP接收来自服务器TCP的 FIN 并响应以一个 ACK ,然而问题是客户进程阻塞在
fgets
调用上,等待从终端接收一行文本。 此时,在另外一个窗口上运行
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
在客户端上再键入一行文本。
another line str_cli: server terminated prematurely
当我们键入 “another line” 时,str_cli调用writen
,客户TCP接着把数据发送给服务器。TCP允许这么做,因为客户TCP接收到 FIN 只是表示服务器进程已关闭了连接的服务器端,从而不再往其中发送任何数据而已。FIN 的接收并没有告知TCP服务器进程已经终止。
当服务器TCP接收到来自客户的数据时,既然先前打开那个套接字的进程已经终止,于是响应以一个RST。
- 然而客户进程看不到这个RST,因为他在调用
writen
后立即调用readline
,并且由于第2步中接收的 FIN ,所调用的readline
立即返回0(表示 EOF )。我们的客户端此时并未预期收到 EOF ,于是出错信息“server terminated permaturely”(服务器过早终止)退出。 - 当客户终止时,它所有打开着的描述符都被关闭。
本例子的问题在于:在 FIN 到达套接字时,客户正阻塞在fgets
调用上。客户实际上在应对两个描述符——套接字和用户输入,它不能单纯阻塞在这两个源中某个特定源的输入上,而是应该阻塞在其中任何一个源的输入上。事实上这正是select
和poll
这两个函数的目的之一,一旦杀死服务器子进程,客户就会立即被告知已收到 FIN 。
SIGPIPE信号
要是客户不理会readline
函数返回的错误,反而写入更多的数据到服务器上,那么又会发送什么呢?这种情况是可能发生的,举例来说,客户可能在读回任何数据之前执行两次针对服务器的写操作。而 RST 是由其中第一次写操作引发的。
使用于此的规则是:当一个进程向某个已收到 RST 的套接字执行写操作时,内核向该进程发送一个SIGPIPE
信号。该信号的默认行为是终止进程,因此进程必须捕获它以免不情愿地被终止。
不论该进程是捕获了该信号并从其信号处理函数返回,还是简单地忽略该信号,写操作将返回EPIPE
错误。
第一次写操作引发 RST,第二次写引发
SIGPIPE
信号。写一个已接收了FIN的套接字不成问题,但是写一个已接收了 RST 的套接字则是一个错误。
1 | void str_cli(FILE *fp, int sockfd) { |
上面的代码修改就是调用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
出了错,那么必须要么不理会该信号,要么从信号处理函数返回后再处理来自write
的EPIPE
。
服务器主机崩溃
为了模拟这种情形,我们必须在不同的主机上运行客户和服务器。我们先启动服务器,再启动客户,接着在客户上键入一行文本以确认连接工作正常,然后从网络上断开服务器主机,并在客户上键入另一行文本。这样同时也模拟了客户发送数据时服务器主机不可达的情形(即建立连接后某些中间路由器不工作)。
步骤如下所述:
- 当服务器主机崩溃时,已有的网络连接上不发出任何东西。这里我们假设的是主机崩溃,而不是由操作员执行命令关机。
- 我们在客户上键入一行文本,它由
writen
写入内核,再由客户TCP作为一个数据分节送出。客户随后阻塞于readline
调用,等待回射的应答。 - 如果我们用
tcpdump
观察网络就会发现,客户TCP持续重传数据分节,试图从服务器上接收一个ACK 。经过一段时间后,客户TCP最后终于放弃时(假设在这段时间内,服务器主机没有重新启动,或者如果是服务器主机为崩溃但是从网络上不可达,那么假设主机仍然不可达),给客户进程返回一个错误。既然客户阻塞在readline
调用上,该调用将返回一个错误。假设服务器主机已崩溃,从而对客户的数据分节根本没有响应,那么所返回的错误是 ETIMEDOUT 。然而如果某个中间路由器判定服务器主机已不可达,从而响应一个“destination unreachable”(目的地不可达)ICMP消息,那么所返回的错误是 EHOSTUNREACH 或 ENETUNREACH 。
尽管我们的客户最终还是会发现对端主机已崩溃或不可达,不多超时的时间是由内核决定的,可能会等待的时间比较常,可以在readline
调用设置一个超时的时间,或用心跳检测程序。
服务器主机崩溃后重启
在这种情形中,我们先在客户与服务器之间建立连接,然后假设服务器主机崩溃并重启。模拟这种情形的最简单方法就是:先建立连接,再从网络上断开服务器主机,将它关机后再重新启动,最后把它从新连接到网络中。我们不想客户知道服务器的关机。
如果在服务器主机崩溃时客户不主动给服务器发送数据,那么客户将不会知道服务器主机已经崩溃。(这里假设我们没有使用SO_KEEPALIVE套接字选项)。所发生的步骤如下所述:
- 我们启动服务器和客户,并在客户键入一行文本以确认连接已经建立。
- 服务器主机崩溃并重启
- 在客户上键入一行文本,它将作为一个TCP数据分节发送到服务器主机。
- 当服务器主机崩溃后重启时,它的TCP丢失了崩溃前的所有连接信息,因此服务器TCP对于所收到的来自客户的数据分节响应以一个 RST 。
- 当客户TCP收到该 RST 时,客户正阻塞于
readline
调用,导致该调用返回 ECONNRESET 错误。
如果对客户而言检测服务器主机崩溃与否很重要,即使客户不主动发送数据也要检测出来,就需要采用其他某种技术(诸如SO_KEEPALIVE套接字选项或某些客户/服务器心跳函数)。
服务器主机关机
Unix系统关机时,init
进程通常先给所有进程发送SIGTERM
信号(该信号可被捕获),等待一段固定的时间(往往在5到20秒之间),然后给所有仍在运行的进程发送SIGKILL
信号(该信号不能被捕获)。这么做留给所有运行的进程一小段时间来清除和终止。如果我们不捕获SIGTERM
信号并终止,我们的服务器将由SIGKILL
信号终止。当服务器进程终止时,它的所有打开着的描述符都被关闭,随后发生的步骤与服务器进程终止一样。正如上面所述,必须在客户中调用select
和poll
函数。使得服务器进程的终止一经发生,客户就能检测到。