简单TCP客户服务器相关

GuangYing Lv1

POSIX信号处理

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

信号可以:

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

每个信号都有一个与之关联的处置,也称行为
通过调用sigaction函数来设定一个信号的处理,并有三种选择

  • 可以提供一个函数,只要有特定信号发生它就被调用
    • 这样的函数称为信号处理函数
    • 这种行为称为捕获信号
    • 有两个信号不能被捕获
      • SIGKILL和SIGSTOP
    • 信号处理函数由信号值这个单一的整数参数来调用,且没有返回值,其函数原型因此如下:
      • void handler(int signo);
    • 对于大多数信号,调用sigaction函数并指定发生时所调用的函数即可
    • 但SIGIO、SIGPOLL、SIGURG等个别信号还需要捕获它们的进程做些其他额外工作
  • 可以把某个信号的处置设定为SIG_IGN来忽略
    • SIGKILL和SIGSTOP这两个信号不能被忽略
  • 可以把某个信号的处置设定为SIG_DFL来启用它的默认处置
    • 默认处置通常是在收到信号后终止进程,其中某些信号还在当前工作目录产生一个进程的核心映像(也称为内存映像)
    • 另有个别信号的默认处置是忽略的,SIGCHLD和SIGURG(带外数据到达时发送)等

signal函数

此处以所期望的PISIX语义提供了一个简单接口

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"

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

act.sa_handler = func;
sigemptuset(&act.sa_mask);
act.sa_flags = 0;
if (signo == SIGALRM) {
#ifdef SA_INTERRUPT
act.sa_flags |= SA_INTERRUPT; /* SunOS 4.x */
#endif
} else {
#ifdef SA_RESTART
act.sa_flags |= SA_RESTART; /* SVR4, 4.4BSD */
#endif
}
if (sigaction(signo, &act, &oact) < 0)
return(SIG_ERR);
return(oact.sa_handler);
}

用typedef简化函数原型
void (*signal(int signo, void (*func)(int)))(int);
定义:typedef void Sigfunc(int);
原型于是变为:
Sigfunc *signal(int signo, Sigfunc *func);
该函数的第二个参数和返回值都是指向信号处理函数的指针

设置处理函数

  • sigaction结构的sa_handler成员被置为func参数

设置处理函数的信号掩码

  • POSIX允许指定一组信号:在信号处理函数被调用时阻塞
    • 任何阻塞的信号都不能提交给进程
    • 把sa_mask成员设置为空集,即运行此处理函数期间,不阻塞额外的信号
    • POSIX保证被捕获的信号在其信号处理函数运行期间总是阻塞的

设置SA_RESTART标志

  • SA_RESTART标志是可选的
    • 如果设置,由相应信号中断的系统调用将由内核自动重启
    • 如果被捕获的信号不是SIGALRM且SA_RESTART有定义,就设置该标志
    • 对SIGALRM特殊处理的原因是,为I/O操作设置超时,此时希望受阻塞的系统调用被该信号中断掉
  • 一些早期系统(如SunOS 4.x)默认设置成自动重启被中断的系统调用,并定义了与SA_RESTART互补的SA_INTERRUPT标志
    • 如果定义了该标志,就在被捕获的信号是SIGALRM时设置它

POSIX信号语义

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

处理SIGCHLD信号

设置僵死(zombie)状态的目的是维护子进程的信息,以便父进程在以后某个时候获取
信息包括子进程的进程ID、终止状态、资源利用信息等

如果一个进程终止,而该进程有子进程处于僵死状态,则它的所有僵死子进程的父进程ID将被重置为1(init进程)
继承这些子进程的init进程将清理它们
有些Unix系统在ps命令输出的COMMAND栏以<defunct>指明僵死进程

处理僵死进程

僵死进程占用内核空间,最终可能导致耗尽进程资源
为此建立一个俘获SIGCHLD信号的信号处理函数,在函数体中调用wait

处理僵死进程的可移植方法:捕获SIGCHLD,并调用wait或waitpid

在父进程阻塞与慢系统调用(accept)时由父进程捕获,内核就会使accept返回一个EINTR错误(被中断的系统调用),而父进程不处理该错误,于是终止

在Solaris 9环境下的特定例子中,标准C库函数中提供的signal函数标志不会使内核自动重启被中断的系统调用

  • 即在自定义的signal中设置的SA_RESTART标志在系统函数库的signal函数中并没有设置
    而另有些系统自动重启被中断的系统调用

处理被中断的系统调用

适用于慢系统调用的基本规则是:当阻塞与某个慢系统调用的一个进程捕获某个信号且相应信号处理函数返回时,该系统调用可能返回一个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");
}
...
}

该代码自己重启被中断的系统调用
对accept、read、write、select、open等函数合适
但connect不能重启

  • 如果该函数返回EINTR,就不能再次调用它,否则将立即返回一个错误
  • 当connect被一个捕获的信号中断而且不自动重启时,必须调用select来等待连接完成

waitwaitpid函数

用来处理已终止的子进程

1
2
3
4
#include <sys/wait.h>
pid_t wait(int *statloc);
pid_t waitpid(pid_t pid, int *statloc, int options);
均返回:若成功则为进程ID,若出错则为0或-1

均返回已终止子进程的进程ID号
通过statloc指针返回子进程终止状态(一个整数)

可以调用三个宏来检查终止状态,并判别子进程是正常终止、由某个信号杀死、由某个信号杀死还是仅仅由作业控制停止而已
另有些宏用于接着获取子进程的退出状态、杀死子进程的信号值或停止子进程的作业控制信号值

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

waitpid函数就等待哪个进程以及是否阻塞给了更多的控制

  • pid参数允许指定想等待的进程ID
  • 值-1表示等待第一个终止的子进程(还有些处理进程组ID的可选值)
  • options参数允许指定附加选项
    • 最常用的选项是WNOHANG,它告知内核在没有已终止子进程时不要阻塞

函数wait和waitpid的区别

如果多个信号都在信号处理函数执行前产生,而信号处理函数只执行一次,因为Unix信号一般是不排队的,此问题是不确定的,指不定执行几次:)

正确的解决办法是调用waitpid而不是wait
在一个循环内调用waitpid以获取所有已终止子进程的状态
必须指定WNOHANG选项,告知waitpid在尚有未终止的子进程在运行时不要阻塞
不能在循环内调用wait,因为没办法防止wait在正运行的子进程中尚有未终止时阻塞

1
2
3
4
5
6
7
8
9
10
#include "unp.h"
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返回的EINTR,并建立一个给所有已终止子进程调用waitpid的信号处理函数

在网络编程时可能会遇到三种情况

  • 当fork子进程时,必须捕获SIGCHLD信号
  • 当捕获信号时,必须处理被中断的系统调用
  • SIGCHLD的信号处理函数必须正确编写,应使用waitpid函数以免留下僵死进程

accept返回前连接中止

另一种情况也能导致accept返回一个非致命的错误,此时只需要再次调用accept

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

如何处理这种中止的连接依赖于不同的实现
源自Berkeley的实现完全在内核中处理中止的链接,服务器进程根本看不到
而大多数SVR4实现返回一个错误给服务器进程,不过错误本身取决于实现

服务器进程终止

当服务器先于客户端终止时(kill)

  • 子进程中所有打开的描述符都被关闭,导致向客户发送一个FIN,而客户TCP相应一个ACK
    • 这是TCP连接终止工作的前半部分
  • SIGCHLD信号被发送给服务器父进程,并得到正确处理
  • 客户端无事发生
    • 客户TCP接收来自服务器TCP的FIN并相应一个ACK
    • 然而问题是:客户进程阻塞在fgets调用上,等待从终端接受一行文本
  • 此时,在客户上键入一行文本
    • 调用writen,客户TCP把数据发送给服务器
      • TCP允许这么做,因为客户TCP接收到FIN只表示服务器进程已关闭了连接的服务器端,从而不在往其中发送任何数据而已
      • FIN的接收并没有告知客户TCP服务器进程已经终止(即使确实已经终止了)
    • 当服务器TCP接受到来自客户的数据时
      • 先前打开的套接字进程已经终止
      • 于是相应一个RST
  • 然而客户进程看不到这个RST
    • 因为此时它在调用writen后立即调用readline
    • 并且由于之前接收的FIN,所调用的readline立即返回0(表示EOF)
    • 客户此时并未预期收到EOF
    • 于是以错误信息server terminated prematurely(服务器过早终止)退出
    • 当客户终止时,所有打开的描述符都被关闭

上述例子还取决于时序,客户调用readline可能发生在服务器的RST被客户收到之前,也可能发生在收到之后

  • 如果readline发生在收到RST之前(上述例子所示),那么结果是客户得到一个未预期的EOF
  • 否则结果是由readline返回一个ECONNRESET(connection reset by peer,对方复位连接错误)

当FIN到达套接字时,客户正阻塞在fgets调用上
客户实际上在应对两个描述符:套接字和用户输入,不能单纯阻塞在这两个元中某个特定源的输入上,而是应该阻塞在其中任何一个源的输入上

  • 是select和poll这两个函数的目的之一
    使用这个函数编写客户端程序,使得一旦杀死服务器子进程,客户就会立即被告知接收到FIN

SIGPIPE信号

客户可能在读回任何数据之前执行两次针对服务器的写操作,而RST是由其中第一次写操作引发的

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

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

在第一次写操作而不是第二次写操作时捕获该信号是不可能的
第一次写操作引发RST,第二次写引发SIGPIPE信号
写一个已经接受了FIN的套接字不成问题,但写一个已接收了RST的套接字则是一个错误

处理SIGIPIE的建议方法取决于它发生时应用进程想做什么

  • 如果没有特殊的事情要做,那么将该信号处理办法直接设置为SIG_IGN,并假设后续的输出操作将捕捉EPIPE错误并终止
  • 如果信号出现时采取特殊措施(可能需要在日志文件中登记),那么就必须捕获该信号,以便在信号处理函数中执行所有期望的动作

必须注意
如果使用了多个套接字,信号的提交无法告诉我们是哪个套接字出的错
如果确实需要知道哪个write出了错,那么必须要么不理会该信号,要么从信号处理函数返回后再处理来自write的EPIPE

服务器主机崩溃

在向服务器主机发送数据时才能检测出它已经崩溃或不可达
若需要不主动向其发送数据也向检测出服务器主机的崩溃,则需采用其他技术:SO_KEEPALIVE套接字选项

服务器主机崩溃后重启

服务器主机崩溃后重启时,TCP丢失了崩溃前的所有连接信息,因此服务器TCP对于所有收到的来自客户的数据分节相应一个RST

客户TCP收到该RST时,客户正阻塞于readline调用,导致该调用返回ECONNRESET错误

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

服务器主机关机

Unix系统关机时

  • init进程通常先给所有进程发送SIGTERM信号(该信号可被捕获)
  • 等待一段固定时间(往往5~20秒之间)
  • 然后给所有仍在运行的进程发送SIGKILL信号(该信号不能被捕获)
    为了留给所有运行的进程一小段时间用来清除和终止

如果忽略SIGTERM信号,服务器将由SIGKILL信号终止

  • SIGTERM信号默认处置就是终止进程
  • 所以不捕获也不忽略则进行默认处置,服务器将被SIGTERM信号终止,SIGKILL信号不会在发送给它

当服务器子进程终止时,所有描述符关闭
与之前所述相同,必须在客户中使用select或poll函数,使得服务器进程的终止一经发送,客户就能检测到

TCP程序小结

在TCP客户和服务器可以彼此通信之前,每一端都得指定连接的套接字对:

  • 本地IP地址
  • 本地端口
  • 外地IP地址
  • 外地端口

从客户的角度,外地IP地址和外地端口必须在客户调用connect时指定,而两个本地值通常就由内核作为connect的一部分来选定

客户也可以在调用connect之前,通过调用bind来指定其中一个或全部两个本地值
不过这种做法并不常见

客户可以在连接建立后通过调用getsockname获取由内核指定的两个本地值

服务器的角度来看
本地端口(服务器的众所周知端口)由bind指定

  • bind调用中服务器指定的本地IP地址通常是通配IP地址
    如果服务器在一个多宿主机上绑定通配IP地址,那么它可以在连接建立后通过调用getsockname来确定本地IP地址

两个外地值由accept调用返回给服务器

如果另一个程序由调用accept的服务器通过调用exec来执行,那么这个新程序可以在必要时调用getpeername来确定客户的IP地址和端口号

数据格式

一般来说,必须关心在客户和服务器之间进行交换的数据格式

在客户与服务器之间传递文本串

若服务器期望从客户读入的文本行包含由空格分开的两个整数,服务器将返回两个整数的和

1
2
3
4
5
6
7
8
9
10
for ( ; ; ) {
if ((n = Readline(sockfd, line, MAXLINE)) == 0)
return;
if (sscanf(line, "%ld%ld", &arg1, &arg2) == 2)
snprintf(line, sizeof(line), "%ld\n", arg1 + arg2);
else
snprintf(line, sizeof(line), "input error\n");
n = strlen(line);
Writen(sockfd, line, n);
}

调用sscanf把本文串中的参数转换为长整数,然后调用snprintf把结果转换为文本串

在客户与服务器之间传递二进制结构

当这样的客户和服务器程序运行在字节序不同或支持长整数大小不一致的两个主机上时,工作将失常

  • 不同的实现以不同的格式存储二进制数
    • 最常见的如大端字节序与小端字节序
  • 不同的实现在存储相同的C数据类型上可能存在差异
    • 比如长整数位数不一定相同
  • 不同的实现给结构打包的方式存在差异
    • 取决于各种数据类型所用的位数以及机器的对其限制
    • 因此穿越套接字传递二进制结构绝对不是明智的

解决这种数据格式问题有两个常用方法:

  • 把所有的数值数据作为文本串来传递
    • 这里假设客户和服务器主机具有相同的字符集
  • 显示定义所支持数据类型的二进制格式(位数、大端或小端字节序),并以这样的格式在客户与服务器之间传递所有数据
    • 远程过程调用(RPC)软件包通常使用这种技术
  • 标题: 简单TCP客户服务器相关
  • 作者: GuangYing
  • 创建于 : 2024-12-25 20:33:17
  • 更新于 : 2024-12-25 20:35:15
  • 链接: http://quebo.cn/2024/12/25/简单TCP客户服务器相关/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。