TCP Client/Server
1. Introduction
本章将编写一个简单的TCP client/server, 主要有以下功能:
- client从standard iunput读取一行文本, 并写给server
- server从network input读取文本, 并将该文本发送回client
- client从network input读取文本, 并输出到standard output
整个TCP client/server的流程如下:fgets()
和fputs()
为standard I/O functions, writen()
和readlin()
为自定义函数
2. TCP Echo Server: main Function
创建一个父进程listen请求, 子进程来处理请求
int main(int argc, char **argv) |
3. TCP Echo Server: str_echo Function
str_echo()
负责从client请求中读取数据, 并将数据发送回client
void str_echo(int sockfd) |
4. TCP Echo Client: main Function
与server连接并传输数据
int main(int argc, char **argv) |
5. TCP Echo Client: str_cli
str_cli()
负责从standard input中读取文本, 发送给server并读取server传回的文本.
void str_cli(FILE *fp, int sockfd) |
6. Normal Startup
$ tcpserv01 & |
当server启动时, 会依次调用socket()
, bind()
, listen()
和accept()
, 并阻塞于accept()
. client运行前, 可执行netstat
命令查看server的socket状态:
$ netstat -a |
从上述可得知: 该socket处于LISTEN
状态, Local IP address为通配符, port number为9877, 可接收任意IP address和port number的请求.
由于client和server处于同一主机, 所有指定server的IP address为127.0.0.1
:
$ tcpcli01 127.0.0.1 |
Client会依次调用socket()
和connect()
. TCP的三次握手完成后, client的connect()
调用完毕, server的accept()
调用完毕. 连接建立后执行以下步骤:
- client调用
str_cli()
, 阻塞于fgets()
, 等待用户在command line输入字符 - server的
accept()
完成调用后, 调用fork()
生成子进程: 子进程调用readline()
等待client传来数据; 父进程继续调用accept()
等待下一个client的请求
这其中存在三个可能阻塞的进程: client, server的prarent process, server的child process. 这时client与server已建立连接但client端还未输入数据, 执行netstat
查看连接状态:
$ netstat -a |
从上向下依次为:
- server子进程: 与client建立连接, 状态为
ESTABLISHED
. - client进程: 与server建立连接, 其port number为kernel随机分配 (42758)
- server父进程: 等待新的client请求
使用ps
命令查看进程之间的状态:
$ ps -t pts/6 -o pid,ppid,tty,stat,args,wchan |
从上向下进程依次为:
- server父进程: 其PID为17870, 处于
sleep
状态, 等待新的client请求 - server子进程: 其PID为19315, 其父进程PID为17870, 处于
sleep
状态, 等待client传入数据 - client进程: 处于
sleep
状态, 等待用户I/O输入
7. Normal Termination
通过输入EOF
(通常为Control-D)可终止client与server的连接:
$ tcpcli01 127.0.0.1 |
输入EOF后立刻执行netstat
命令:
$ netstat -a | grep 9877 |
以下是server和client正常终止连接的整个步骤:
- client端输入EOF字符,
fgets()
返回NULL,str_cli()
退出while循环并返回 - client端
main()
中调用exit()
开始退出进程 - 进程退出时会关闭所有开启的file descriptor, 其中client的socket会被kernel关闭. client向server发送FIN, server回应ACK. 此时TCP 4-way handshake termination进行一半, server端socket处于
CLOSE_WAIT
状态, client端socket处于FIN_WAIT_2
状态. - server收到FIN后, server子进程中
read()
返回0,str_echo()
返回. - server子进程调用
exit()
, 开始关闭所有打开的file descriptor, 这时开始完成TCP 4-way handshake termination的后半段: server向client发送FIN, client回复ACK, client socket进入TIME_WAIT
状态. - server子进程中止时向父进程发送
SIGCHLD
signal. 由于server父进程未设置signal handler, 因此server子进程成为zombie(僵尸进程):$ ps -t pts/6 -o pid,tty,stat,args,wchan
PID PPID TT STAT COMMAND WCHAN
17870 22038 pts/6 S ./tcpserv01 wait_for_connect
19315 17870 pts/6 Z ./tcpserv01 do_exit
8. POSIX Signal Handling
signal可以理解为发送给进程的notification, 通知进程发生了某件事, 也称为software interrupt. signal是异步的, 因此进程无法得知signal何时到达. signal可由进程发出, 也可由kernel发出. SIGCHLD
作为signal的一种, 由kernel发出, 用于通知父进程其子进程已中止.
每个signal都有一个disposition, 也称为action. 调用sigaction()
可设置当前进程对某个signal的disposition:
- 为特定signal提供一个signal handler: 进程捕获该signal后自动执行signal handler (
SIGKILL
和SIGSTOP
无法设置signal handler):void handler(int signo);
- 忽略signal: 将disposition设置为
SIG_IGN
可忽略指定signal (SIGKILL和SIGSTOP不可忽略) - 默认disposition: 将disposition设置为
SIG_DFL
会启用default disposition, 一般接收到signal后会中止进程, 部分signal的默认disposition为忽略该signal
8.1 signal Function
typedef void Sigfunc(int); // a function with an integer argument |
8.2 POSIX Signal Semantics
POSIX的系统信号处理总结为以下几点:
- 一旦设置了signal handler, 该signal handler会一直存在(旧版UNIX系统执行完毕后会移除signal handler)
- 为保证一些关键代码不被signal handler中断, 需阻塞signal.
sigaction.sa_mask
指定哪些signal会被阻塞, 将sa_mask
置为NULL表示: 除当前signal外, 不会阻塞其他signal. - 若多个signal被阻塞, 当进程解除阻塞时, 只会提交一个signal. 默认情况下UNIX的signal依照时序排队, 但POSIX real-time standard定义了某些signal会依照时序进入队列.
sigpromask()
可阻塞或解除阻塞指定的signal集合, 这使得开发者可以保证某段代码不被signal打断.
9. Handle SIGCHLD Signals
Kernel将进程设置为zombie状态是为了保留子进程的信息, 让其父进程可以稍后获取这些信息, 其中包括: PID, termination status和子进程的资源利用率(CPU time, memory等). 如果父进程未处理其子进程的zombie状态且终止运行, 其所有子进程的PPID(parent PID)将置为1(init进程), 并负责清理这些zombies.
9.1 Handle Zombies
若不及时处理这些zombie, 会占用大量kernel空间. 当调用fork()
产生子进程后, 应调用wait()
防止子进程变为zombie. 因此, 可为SIGCHLD
signal创建一个signal handler, 在handler中调用wait()
:
Signal(SIGCHLD, sig_chld); |
注意, 必须在调用fork()
之前调用Signal (SIGCHLD, sig_chld);
. 加入signal handler后的运行结果如下:
$ tcpserv02 & |
具体步骤:
- client端输入EOF, client向server子进程发送FIN, server子进程回复ACK
- server子进程终止运行
- 被
accept()
阻塞的server父进程被SIGCHLD signal打断, 并开始执行signal handler, 调用printf()
输出子进程PID - 由于server父进程的
accept()
被打断, 返回EINTR
部分操作系统会自动重启被signal打断的system call, 因此accept()
不会报错, 例如4.4BSD.
9.2 Handle Interrupted System Calls
当slow system call被阻塞时, 指代那些可能永远处于阻塞状态的system call, 绝大多数network function都属于slow system call. 例如accept()
, 假如没有client发起连接请求, 则accept()
将一直处于阻塞状态; server的read()
也是同理, 假如没有client发送数据, 则read()
将一直被阻塞.
当slow system call被阻塞时, 若其被signal打断, 则slow system call会将errno设置为EINTER. 不同UNIX系统对signal的中断有不同的处理方法, 即使在sigaction struct
的flag标记为SA_RESTART
, 也不能保证被中断的system call自动重启. 因此需要对这些可能被signal打断的slow system call进行额外处理:
for ( ; ; ) { |
对于上述代码来说: 若系统不支持自动重启被打断的accept()
, 则再次进入for循环; 否则for循环中的accept()
自动重启, 不会再次进入循环.
10. wait and waitpid Functions
wait()
用于处理中止的子进程.
|
函数wait()
和waitpid()
均返回两个值: 子进程的PID和中止状态. 如果当前没有已中止的子进程, 那么wait()
会一直阻塞直到有子进程终止. waitpid()
可以指定某个进程的ID并指定附加选项, 最常用的option就是WNOHANG
, 表示在没有终止子进程时不要阻塞.
10.1 Difference between wait and waitpid
为展示wait()
与waitpid()
的不同, 需要对client端代码进行修改, 让client端与server建立5个连接:
int main(int argc, char **argv) |
当client调用exit(0)
时, 会向各自server发送FIN, server父进程同时收到5个SIGCHLD
signal, 如下图:
以下是运行结果:
$ tcpserv03 & |
只有第一个子进程的SIGCHLD
signal被捕获, 其他4个子进程变成zombies:
PID TTY TIME COM |
可以得知, wait()
并不足以防止zombie出现. 因为相同类型的signal不会进入队列等待: 当第二个SIGCHLD
到达时, 发现第一个SIGCHLD
占用signal handler, 因而被阻塞; 但当第三个SIGCHLD
到达时, 发现第一个SIGCHLD
依然占用signal handler, 将会丢弃该signal. 由于不同signal handler的处理时长不同, 生成多个同类型signal时, 可能出现不同数量的zombie. 解决方法是在signal handler中使用while
循环, 不断查询是否出现中止的child process:
void sig_chld(int signo) |
waitpid()
可通过WNOHANG
保证没有子进程中止时立即返回, wait()
则无法设置为非阻塞模式.
10.2 Correct version of TCP server
以下是能够正确处理accept()
发出的EINTR
和防止zombie出现的server端:
int main(int argc, char **argv) |
11. Connection Abort before accept Returns
还有另外一种打断slow system call的情况: 在client和server完成TCP 3-way handshake后, client发送RST, 而此时连接仍在队列中等待server调用accept()
接收.
不同的系统对此有不同的处理:
- Berkeley-derived会自动将该completed connection从队列中移除
- POSIX会将errno设为
ECONNABORTED
- SVR4 implementation会将errno设为
EPROTO
12. Termination of Server Process
为模拟server崩溃的情况, 需要先让client和server建立连接, 然后kill掉server的子进程, 以下是操作步骤:
- 依次启动server和client, 让client发送一行文本来测试连接是否成功
- 找到server子进程的PID并调用kill杀死. server子进程被终止后, 其所有open descriptor也会被关闭, 其connected socket会向client发送FIN, client回应ACK
- server父进程接收到
SIGCHLD
signal并读取termination status - client的
fgets()
被阻塞, 会一直等待用户在command-line输入文本 netstat
的执行结果如下:可以看出, TCP 4-way handshake termination已经进行了一半.$ netstat -a | grep 9877
Proto Recv-Q Send-Q Local Address Foreign Address State
tcp 0 0 *:9877 *:* LISTEN
tcp 0 0 localhost:9877 localhost:43604 FIN_WAIT2
tcp 1 0 localhost:*:43604 localhost:9877 CLOSE_WAIT- 继续在client端输入文本, 结果如下: 当输入
$ tcpcli01 127.0.0.1
hello
hello
// here we kill the server child process
another line
str_cli: server terminated prematurelyanother line
时,str_cli()
仍会调用调用writen()
, 因为虽然client接收到FIN, 但并不意味着server已经停止. - 由于server子进程已经关闭, 所以没有相对应的进程回应, kernel自动回复RST.
- client收到FIN,
readline()
返回0. 因此server传来的RST并没有收到, client中止运行并关闭所有file descriptor.
若收到RST后调用readline()
, 则返回ECONNRESET
. 本例的问题在于: 当client收到FIN时, client正在被fgets()
阻塞. client端有两个file descriptor: 与server相连的socket和user input. 当前str_cli()
中只能在同一时间阻塞其中一个file descriptor, 可使用select
或poll
实现任意file descriptor的阻塞.
13. SIGPIPE Signal
若client忽略readline()
的错误并继续向server传递数据, 则client进程会收到SIGPIPE
signal, 该signal默认中止进程, 但进程可捕获该signal, 为其设置signal handler或忽略. 若进程继续调用write()
传送数据, 则write()
返回EPIPE
:
void str_cli(FILE *fp, int sockfd) |
注意: 由于第一次writen()
只会收到RST; 只有第二次调用writen()
才能收到SIGPIPE
signal. 所以上述代码将writen()
拆成两次运行, 以下是运行结果:
$ tcpcli11 127.0.0.1 |
可以看出, 第二次调用writen()
会直接触发SIGPIPE
signal, 进程被终止运行. 可以根据应用所处的情况, 为SIGPIPE
signal设置signal handler. 若程序中有多个socket使用writen()
, signal handler无法告知是哪个writen()
引起的SIGPIPE
signal, 只能通过writen()
的返回值是否为EPIPE
判断.
14. Crash of Server Host
为测试server host崩溃的情况, 必须将client与server分别运行在不同的host上. 先启动server, 再启动client, 在client中输入一行文字确保connection已经建立. 之后断开server的网络连接, 再在client端输入一段文字, 步骤如下:
- 这里假设不是shut down, 而是crash, 所以server端不会有任何信息发送给client.
- 当在client端输入文本后, 会调用
writen()
发送给server, 紧接着被readline()
阻塞. - 由于TCP要求发送的每一个报文都必须有ACK确认, 所以client会不断重发数据, 而不同系统的重传机制不同. 当client最终放弃重传后, client进程会收到一个错误. 由于client阻塞于
readline()
, 所以readline()
会返回ETIMEDOUT
. 如果某个中间路由器发现server不可达, 则会回复client一个名为"destination unreachable"的ICMP报文, client返回EHOSTUNREACH
或ENETUNREACH
倘若不想依赖于系统的重传机制, 可在readline()
中设置倒计时来判断对端是否unreachable. 本例中只能通过向server发送数据才可得知server host crash, 也可通过设置SO_KEEPALIVE
来获知对端是否crash.
15. Crash and Reboot of Server Host
为模拟server崩溃后重启的情况, 需要先建立server与client的连接, server断开网络并重启程序, 最后接入网络, 步骤如下:
- 启动server和client, client端输入数据来确保连接成功
- server crashes and reboots
- client端输入数据, 并传给server
- 由于server重启后丢失所有连接信息, 所以并不识别client的数据, 回复RST
- client收到RST后,
readline()
返回ECONNRESET
16. Shutdown of Server Host
当server host关机时, init进程会向所有正在运行的进程发送SIGTERM
, 等待一定时间(一般为5-20秒)后再发出SIGKILL
, 这给予进程一个安全结束的时间. 若server不设置signal handler捕获SIGTERM
, 则server最后会被SIGKILL
强制终止, 所有open file descriptor都会被关闭, 这就与Termination of Server Process情况相同.
17. Summary of TCP Example
无论是client还是server, 连接前需配置以下属性:
- local IP address
- local port
- foreign IP address
- foreign port
从client的角度:
Foreign IP和foreign port作为connect()
的参数; 而local IP address和local port可由connect()
自动选择, 也可调用bind()
指定local IP address和local port:从server的角度:
server通过调用bind()
设置local IP address和local port. 若bind()
中使用0作为local port或使用通配符作为local IP address, 则需要调用getsockname()
获取local IP address或local port. foreign IP address和foreign port需要调用accept()
获取, 也可通过getpeername()
获取.
18. Data Format
上述例子中server不会检查数据格式, 只会读入一行数据. 在实际项目中必须关注数据交换的格式
18.1 Pass Text Strings between Client and Server
本例server既然从client获取一行数据, 但数据必须包含两个整数, 并以空格分割:
|
18.2 Pass Binary Structures between Client and Server
现在将client和server修改为传递二进制值, 下面是client端的str_cli()
struct args { |
- 测试1: 在两个SPARC主机上运行client和server
$ tcpcli09 12.106.32.254
11 22
33 // correct
-11 -44
-55 // correct - 测试2: 在两个不同的主机上运行client和server(例如: client在big-endian order的SPARC上运行, server在little-endian order的linux上运行)
$ tcpcli09 206.168.112.96
1 2
3 // correct
-22 -77
-16777314 // wrong
上述例子存在三个问题:
- 不同系统以不同的格式存储二进制数, 例如: little-endian和big-endian
- 不同系统对于相同的数据类型有不同的解释, 例如32位UNIX的long使用32bits, 64位UNIX的long使用64bits
- 不同系统pack structure的方式不同
两种通用的解决方法:
- 将numeric data转换为text string传递
- 显式定义所支持的数据类型的binary formats, 例如: number of bits, big or little-endian