网络编程笔记(二)-TCP客户/服务器示例
网络编程笔记(二)-TCP客户/服务器示例
参考《UNIX网络编程》第 5 章,《TCP/IP 网络编程》 第 10 章。
回射(echo)客户/服务器原理概述
并发服务器端实现模型和方法:
- 多进程服务器:通过创建多个进程提供服务。
- 多路复用服务器:通过捆绑并统一管理 I/O 对象提供服务(select 和 epoll)。
- 多线程服务器:通过生成与客户端等量的线程提供服务。
这里学习第一种——多进程服务器。
需要用到的 linux 命令:
ps au
:查看进程 ID 和状态。./可执行文件 &
:后台运行某个进程。
原始并发服务器的实现:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <signal.h>
#include <sys/wait.h>
#include <sys/socket.h>
#define BUF_SIZE 100
#define LISTENQ 5
void error_handling(char *message);
void str_echo(int sockfd);
int main(int argc, char **argv)
{
int listenfd, connfd;
pid_t childpid;
socklen_t clilen;
struct sockaddr_in cliaddr, servaddr;
if (argc != 2)
{
printf("Usage : %s <port>\n", argv[0]);
exit(1);
}
listenfd = socket(AF_INET, SOCK_STREAM, 0);
if (listenfd == -1)
{
error_handling("socket error");
}
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(atoi(argv[1]));
if (bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) == -1)
error_handling("bind() error");
if (listen(listenfd, LISTENQ) == -1)
error_handling("listen() error");
for (;;)
{
clilen = sizeof(cliaddr);
connfd = accept(listenfd, (struct sockaddr *)&cliaddr, &clilen);
if (connfd == -1)
continue;
else
printf("new client %d... \n", connfd);
if ((childpid = fork()) == 0)
{ /* child process */
close(listenfd); /* close listening socket */
str_echo(connfd); /* process the request */
// close(connfd);
printf("%d client disconnected...", connfd);
exit(0);
}
else if (childpid == -1)
{
close(connfd);
puts("fail to fork");
continue;
}
close(connfd); /* parent closes connected socket */
}
}
void str_echo(int sockfd)
{
ssize_t n;
char buf[BUF_SIZE];
while ((n = read(sockfd, buf, BUF_SIZE)) > 0)
write(sockfd, buf, n);
}
void error_handling(char *message){
fputs(message,stderr);
fputs("\n",stderr);
exit(1);
}
客户端:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <signal.h>
#include <sys/wait.h>
#include <sys/socket.h>
#define BUF_SIZE 100
void error_handling(char *sendline);
void str_cli(FILE *fp, int sockfd);
int main(int argc, char **argv)
{
int sockfd;
struct sockaddr_in serv_addr;
if (argc != 3)
{
printf("Usage : %s <IP> <port>\n", argv[0]);
exit(1);
}
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd == -1)
{
error_handling("socket() error");
}
bzero(&serv_addr, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = inet_addr(argv[1]);
serv_addr.sin_port = htons(atoi(argv[2]));
if (connect(sockfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) == -1)
error_handling("connect() error\r\n");
else
printf("Connected....\n");
str_cli(stdin, sockfd); /* do it all */
close(sockfd);
exit(0);
}
void str_cli(FILE *fp, int sockfd)
{
int str_len = 0;
while (1)
{
char sendline[BUF_SIZE], recvline[BUF_SIZE];
printf("Input sendline(Q to quit):\n");
fgets(sendline, BUF_SIZE, stdin);
if (!strcmp(sendline, "q\n") || !strcmp(sendline, "Q\n"))
break;
write(sockfd, sendline, strlen(sendline));
str_len = read(sockfd, sendline, BUF_SIZE - 1);
sendline[str_len] = 0;
printf("sendline from server : %s \n", sendline);
}
}
void error_handling(char *sendline)
{
fputs(sendline, stderr);
fputs("\n", stderr);
exit(1);
}
POSIX 信号处理
信号的定义
信号(signal)就是告知某个进程发生了某个事件的通知;信号通常是异步发生的,也就是说接受信号的进程不知道信号的准确发生时刻。
信号可以:
- 一个进程发给另一个进程;
- 内核发给某个进程。
信号的处置
每个信号都有一个与之关联的处置,即收到特定信号时的处理方法;可以通过调用 sigaction
函数来设定一个信号的处置。
处置方法有三种选择:
-
提供一个函数,只要有特定信号发生它就被调用。这样的函数称为信号处理函数(signal handler),这种行为称为捕获(catching)信号。有两个信号 SIGKILL 和 SIGSTOP 不能被捕获。信号处理函数由信号值这个单一的整数参数来调用,且没有返回值,其函数原型如下:
void handler(int signo);
-
可以把某个信号的处置方法设定为 SIG_IGN 来忽略(ignore)它。SIDKILL 和 SIDSTOP 这两个信号不能被忽略;
-
可以把某个信号的处置方法设定为 SIG_DEF 来启用它的默认(default)处置,默认初值通常是收到信号后终止进程。另有个别信号的默认处置为忽略,如 SIGCHLD 和 SIGURG。
第一种处置方法
建立信号处置的 POSIX 方法就是调用 sigaction 函数,但比较复杂(简单方法是调用自带的 signal 函数)。POSIX 明确规定了调用 sigaction 时的语义定义。解决方法是定义自己 signal——只是调用 sigaction 函数,以所期望的 POSIX 语义提供一个简单的接口。
UNIX 系统自带的 signal 函数,历史悠久,不太稳定,也叫信号注册函数。
#include <signal.h>
// 功能:返回之前注册的函数指针。
// 参数:int signo,void (*func)(int)
// 返回类型:参数为int型,返回为void型函数指针
void (*signal(int signo, void (*func)(int)))(int);
一些常见的信号值:
- SIGALARM:已到通过 alarm 函数注册的时间
- SIGINT:输入 CTRL + C
- SIGCHILID:子进程终止
利用 sigaction 函数进行信号处理,可以代替 signal,也更加稳定(POSIX 明确规定了调用 sigaction 时的信号语义)。signal 函数在 UNIX 的不同系列操作系统中可能存在区别,但是 sigaction 完全相同。
#include <signal.h>
/*
参数:
signo:传递的信号
act:对应于第一个参数的信号处理函数
oldact:获取之前注册的信号处理函数指针,若不需要则传递0
返回值:成功返回0,失败返回-1
*/
int sigaction(int signo, const struct sigaction *act, struct sigaction *oldact);
声明并初始化结构体以调用上述函数:
struct sigaction {
void (* sa_handler)(int); // 保存信号处理函数的指针
sigset_t sa_mask; // 可初始化为0
int sa_flags; // 可初始化为0
};
第二种处置方法
把某个信号的处置方法设定为 SIG_IGN 来忽略(ignore)它。SIDKILL 和 SIDSTOP 这两个信号不能被忽略。
第三种处置方法
可以把某个信号的处置方法设定为 SIG_DEF 来启用它的默认(default)处置,默认处置通常是收到信号后终止进程。有个别信号的默认处置为忽略,如 SIGCHLD 和 SIGURG。
处理 SIGCHLD 信号(僵死进程)
僵死进程的概念
进程 ID:创建时进程都会从操作系统获得进程 ID,其值为大于 2 的整数(1 为分配给操作系统启动后的首个进程)。
通过 fork 函数创建进程:复制正在运行的、调用 fork 函数的进程,父子进程拥有完全独立的内存结构。两个进程都执行 fork 函数以后的语句,共享同一代码。
初始服务器的代码存在僵死进程问题。
僵死进程:目的是为了维护子进程的信息(进程ID,终止状态,资源利用信息),以便父进程在以后某个时候存取。如果父进程未主动要求获得子进程的结束状态值,操作系统将让子进程长时间处于僵死状态。僵死进程占用内存中的空间,最终可能导致耗尽内核资源。
启动初始服务器:
启动初始客户端并连接服务器,可以看到,断开连接后出现僵死进程(Z):
销毁僵死进程的方法
wait 函数
利用 wait 函数销毁僵死进程的原理:父进程主动请求获取子进程的返回值。
#include <sys/wait.h>
// 返回值:成功时返回终止的子进程ID,失败返回-1
pid_t wait(int * statloc);
wait 和 waitpid 均返回两个值:已终止子进程的进程 ID 号,以及通过 statloc 指针返回的子进程终止状态(一个整数)。子进程终止状态需要通过下列宏分离:
-
WIFEXITED:子进程正常终止时返回 TRUE。
-
WEXITSTATUS:返回子进程的返回值。
wait(&status);
if (WIFEXITED(status)){
printf("Child pass num : %d", WEXITSTATUS(status));
}
waitpid 函数
wait 的局限性:调用 wait 函数时,如果没有已终止的子进程,那么程序将阻塞(Blocking)直到有子进程终止。wait 函数不能处理客户端与服务器同时建立多个连接的情况(《UNIX 网络编程》P109-111)
wait 函数会引起程序阻塞,但 waitpid 函数不会阻塞,而且可以指定等待的目标子进程,options 指定为 WNOHANG 时没有终止子进程也不会阻塞。
#include <sys/wait.h>
/*
参数:
pid:等待终止的目标子进程ID,若传递-1,则与wait函数相同,等待任意子进程
statloc:与wait函数的statloc参数一致
options:传递头文件sys/wait.h中声明的常量 WNOHANG,即使没有终止子进程也不会阻塞,而是返回0并退出函数
*/
// 返回值:成功时返回终止子进程ID,失败返回-1
pid_t waitpid(pid_t pid, int * statloc, int options);
// Example:
while (!waitpid(-1, &status, WNOHANG)){
sleep(1);
puts("sleep 1sec.");
}
if (WIFEXITED(status)){
printf("child send %d \n",WEXITSTATUS(status));
}
使用 signal 消除僵死进程
-
在服务器程序中调用 listen 之后添加信号注册函数 signal:
signal(SIGCHLD, sig_chld);
-
编写信号处理函数 sig_chld:
// 版本1:使用 wait void sig_chld(int signo){ pid_t pid; int stat; pid = wait(&stat); printf("child %d terminated\n",pid); return; } // 版本2,使用 waitpid void sig_chld(int signo){ pid_t pid; int stat; while( (pid = waitpid(-1, &stat, WHOHANG)) > 0 ) printf("child %d terminated\n",pid); return; }
可以看到,没有僵死进程:
使用 sigaction 消除僵死进程
类似 signal,在服务器程序中调用 listen 之后添加以下代码,使用同样的信号处理函数 sig_chlid。
// 处理僵死进程
struct sigaction act;
int state;
act.sa_handler = sig_chld;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
state = sigaction(SIGCHLD, &act, 0);
使用 waitpid 而不是 wait 的原因——UNIX 信号不排队
一个客户与并发服务器建立 5 个连接时,建立一个信号处理函数并在其中调用的 wait 不足以防止出现僵死进程(只能终止一个进程)。原因:所有 5 个信号都在信号处理函数执行之前产生,而信号处理函数只执行了一次,因为 Unix 信号一般是不排队的。正确的解决方法是调用 waitpid 而不是 wait:在一个循环内调用 waitpid,以获取所有已终止子进程的状态。WHOHANG 告知 waitpid 没有已终止子进程时也不要阻塞。
void sig_chld(int signo){
pid_t pid;
int stat;
while( (pid = waitpid(-1, &stat, WHOHANG)) > 0 )
printf("child %d terminated\n",pid);
return;
}
小结
- 当 fork 子进程时,必须捕获 SIGCHLD 信号。
- 当捕获信号时,必须处理被中断的系统调用。(P107)
- SIGCHLD 的信号处理函数必须正确编写,应使用 waitpid 函数以免留下僵死进程。
服务器进程终止
复习 TCP 四次握手关闭连接的过程:
模拟服务器进程崩溃时,客户端会发生什么:
-
找到服务器子进程的进程 ID,并执行 kill 命令杀死它。此时被杀死的服务器子进程的所有打开着的描述符都将关闭。这就导致服务器向客户端发送一个 FIN,而客户端会向服务器响应一个 ACK。是四次握手关闭连接的前半部分。
-
SIGCHLD 信号被发送给服务器父进程,僵死子进程得到正确处理。然而问题是客户进程此时阻塞在 fgets 函数上,等待从终端接收一行文本。
-
此时在另一个窗口运行 netstat 命令,可以看到 TCP 连接终止序列的前半部分已经完成。
服务器终端:
[qhn@Tommy tcpcliserv]$ ps au USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND root 1910 0.1 0.3 341764 7732 tty1 Ssl+ 19:26 0:04 /usr/bin/X :0 -background none -noreset -audit 4 -verbose -auth /run qhn 3840 0.0 0.1 117096 2772 pts/0 Ss 19:44 0:00 /usr/bin/bash qhn 6772 0.0 0.0 6388 544 pts/0 S 20:30 0:00 ./tcpserv04 qhn 7240 0.0 0.1 116968 3184 pts/1 Ss 20:32 0:00 /usr/bin/bash qhn 8104 0.0 0.0 6396 396 pts/1 S+ 20:39 0:00 ./tcpcli01 127.0.0.1 qhn 8105 0.0 0.0 6388 104 pts/0 S 20:39 0:00 ./tcpserv04 qhn 8159 0.0 0.0 155448 1872 pts/0 R+ 20:39 0:00 ps au [qhn@Tommy tcpcliserv]$ kill 8105 [qhn@Tommy tcpcliserv]$ child 8105 terminated [qhn@Tommy tcpcliserv]$ netstat -a | grep 9877 tcp 0 0 0.0.0.0:9877 0.0.0.0:* LISTEN tcp 0 0 localhost:9877 localhost:51616 FIN_WAIT2 tcp 1 0 localhost:51616 localhost:9877 CLOSE_WAIT
-
此时,在客户端键入一行文本 "another line",str_cli 调用 written,客户 TCP 将数据发送给服务器(FIN 的接收并没有告知客户 TCP 服务器进程已经终止,但实际上在本例中服务器进程已经被杀死了)。由于服务器先前打开的连接套接字已经终止,于是响应以一个 RST。
-
客户进程之前阻塞在 fgets 上,看不到这个 RST。客户发送 "another line" 后立即调用 readline,直接收到终止符 EOF(因为之前客户端收到了 FIN),这是客户未预期的,所以客户端会提示以出错信息 "server terminated prematurely"(服务器过早终止)退出。
客户端终端:
[qhn@Tommy tcpcliserv]$ ./tcpcli01 127.0.0.1 hello hello hi hi another line str_cli: server terminated prematurely [qhn@Tommy tcpcliserv]$
本例的问题在于:当 FIN 到达客户套接字时,客户正阻塞在 fgets 调用上,不能够及时处理。客户端实际上在应对两个描述符——套接字和用户输入。它不能单纯阻塞在这两个源中某个特定源的输入上,而是应该同时阻塞在这两个源的输入上。这正是 select 和 poll 这两个函数的目的之一。
服务器主机崩溃
在不同的主机上运行服务器和客户端,先启动服务器,再启动客户端,确定它们正常启动后,从网络上断开服务器主机,并在客户键入一行文本。
- 当服务器主机崩溃后(不是由操作员执行命令关机),已有的网络连接上不再发出任何东西。
- 此时客户键入一行文本,文本由 writen 写入内核,再由客户 TCP 作为一个数据分节发出。然后客户阻塞在 readline 调用,等待服务器回射应答。
- 此时用 tcpdump 就会发现,客户 TCP 持续重传数据分节,试图从服务器上接收一个 ACK。
- 既然客户阻塞在 readline 调用上,该调用会返回一个错误:
- 假设服务器已经崩溃,对客户的数据分节根本没有响应,返回错误 ETIMEDOUT;
- 如果某个中间路由器判定服务器已不可达,则该路由器会响应一个 “destination unreachable” (目的地不可达)ICMP 消息,返回错误为 **EHOSTUNREACH **或 ENETUNREACH。
本例的问题在于:想要知道服务器主机是否崩溃,只能通过客户向服务器主机发送数据来检验。如果想不发送数据就检测出服务器主机是否崩溃,需要使用 SO_KEEPALIVE 套接字选项。
服务器主机崩溃并重启
服务器主机崩溃并重启时,在客户上键入一行文本。重启后,服务器 TCP 丢失了崩溃前所有连接信息,因此 TCP 对客户响应一个 RST(重置连接)。当客户 TCP 收到该 RST 时,客户正阻塞于 readline 调用,导致该调用返回 ECONNRESET 错误。
服务器主机关机
服务器主机被操作员关机将会发生什么:Unix 系统关机时,init 进程会给所有进程发送一个 SIGTERM 信号(该信号可被捕获),等待一段固定时间(5~12s),然后给所有仍在运行的程序发送给一个 SIGKILL(该信号不能被捕获)。这么做的目的是,留出一小段时间给所有运行的进程来清除与终止。
如果不捕获 SIGTERM 信号并终止,服务器将由 SIGKILL 信号终止。当服务器子进程终止时,它的所有打开着的描述符都被关闭,这样又回到了服务器进程终止的问题。
数据格式
在客户与服务器之间传递二进制值时,如果字节序不一样或所支持的长整数的大小不一致,将会出错。
3 个问题:
- 不同的实现以不同的格式存储二进制数——大端字节序与小端字节序。
- 不同的实现在存储相同的 C 数据类型上可能存在差异——大多数 32 位 Unix 系统使用 32 位表示长整数,而 64 位系统一般使用 64 位表示长整数。
- 不同的实现给结构打包的方式存在差异。因此,穿越套接字传送二进制结构绝不是明智的。
解决方法:
- 把所有的数值数据作为文本串来传递。
- 显式定义所支持数据类型的二进制格式(位数、大端或小端字节序),并以这样的格式在客户与服务器之间传递所有数据。远程过程调用(RPC)通常使用这种技术。
总结
从简单的 echo 服务器开始,解决了以下问题:
- 处理僵死子进程——采用信号处理(signal,sigaction)。
- 服务器进程终止时,客户进程收到 FIN 但并不知道终止——使用 select、poll。
- 服务器主机崩溃时,必须通过客户向服务器发送数据才能检验—— SO_KEEPALIVE 套接字选项。
- 穿越套接字传送二进制结构绝不是明智的——把所有的数值数据作为文本串来传递。
参考资料
https://www.cnblogs.com/soldierback/p/10690783.html