第5章 TCP 客户/服务器

目录

POSIX 信号

sigaction 函数

POSIX信号语义

处理 SIGCHLD 信号

wait 和 waitpid 函数

accept 返回前连接中止

SIGPIPE 信号

服务器主机崩溃

服务器主机崩溃后重启

数据格式


POSIX 信号

信号就是告知某个进程发生了某个事件的通知,有时也称为软件中断。信号可以:

  • 由一个进程发给另一个进程(或自身);

  • 由内核发给某个进程。

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

  • 我们可以提供一个函数,只要有特定的信号发生它就被调用。这种函数称为信号处理函数,这种行为称为捕获信号。有两个信号不能被捕获,它们是 SIGKILL 和 SIGSTOP 。信号处理函数的格式如下:

void handler ( int signo );
  • 我们可以把某个信号的处置设定为 SIG_IGN 来忽略它。SIGKILL 和 SIGSTOP 不能被忽略。

  • 我们可以把某个信号的处置设定为 SIG_DEF 来启用它的默认处置。默认处置通常是在收到信号后终止进程,另有个别信号的默认处置是忽略--如 SIGCHLD 和 SIGURG 。


sigaction 函数

该函数用来改变进程接收到一个指定信号的操作。

#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);

struct sigaction {
     void     (*sa_handler)(int);
     void     (*sa_sigaction)(int, siginfo_t *, void *);
     sigset_t   sa_mask;
     int        sa_flags;
     void     (*sa_restorer)(void);
};

主要参数说明:

sa_handler 是信号处理函数

sa_mask 处理函数的信号掩码:POSIX 允许我们指定这样一组信号,它们在信号处理函数被调用时阻塞,任何阻塞的信号都不能递交给进程。如果把 sa_mask 成员置空集,意味着在改信号处理函数运行期间,不阻塞额外的信号。而且 POSIX 保证被捕获的信号在其信号处理函数运行期间总是阻塞的,也就是说此时其他的信号不能递交给进程。


POSIX信号语义

  • 在一个信号处理函数运行期间,正被递交的信号是阻塞的。意思是如果这里我们正在处理一个SIGCHLD信号,那么其他信号的处理将会滞后。

  • 如果一个信号在被阻塞期间产生了一次或多此,那么该信号被解阻塞之后通常只递交一次,也就是说Unix信号默认是不排队的。例如,子进程提交了一个SIGCHLD信号,而此时内核正在处理运行另一个进程的信号处理函数,且该子进程再继续递交了多次SIGCHLD信号,那么唤醒时只会递交一次SIGCHLD信号。

  • 利用 sigprocmask 函数选择性地阻塞或解阻塞一组信号是可能的。这使得我们可以做到在一段临界区代码执行期间,防止捕获某些信号,以此保护这段代码。


处理 SIGCHLD 信号

设置僵死状态的目的是维护子进程的信息,以便父进程在以后的某个时候获取。注意区别僵尸进程和孤儿进程。

孤儿进程:一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。孤儿进程将被init进程(进程号为1)所收养,并由init进程对它们完成状态收集工作。

僵尸进程:一个进程使用fork创建子进程,任何一个子进程(init除外)在exit()之后,并非马上就消失掉,而是留下一个称为僵尸进程(Zombie)的数据结构,等待父进程处理。如果子进程退出,而父进程并没有调用wait或waitpid获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中。这种进程称之为僵死进程。

孤儿进程并没有什么危害,而僵尸进程却危害极大。消灭僵尸进程的方法就是找到其父进程,kill 掉,那么这些僵尸进程就会变成孤儿进程。

无论何时我们 fork 子进程都得 wait 它们,以防止它们变成僵死进程。我们需要建立一个俘获 SIGCHLD 信号的信号处理函数,在这个函数体中我们调用 wait 。如下是个简单描述,但这样写并不好,随后讨论:

void sig_chld ( int signo )
{
    pid_t pid;
    int stat;
    
    pid = wait(&stat);
    return;
}

慢系统调用:指那些并不立即返回,可能永远阻塞的系统调用,如 accept 函数,以及某些情况下的 read 函数。

适用于慢系统调用的基本规则是:当阻塞于某个慢系统调用的一个进程捕获某个信号且相应信号处理函数返回时,该系统调用可能返回一个 EINTR(Interrupted system call)错误。如 read 等待输入期间,如果收到一个信号,系统将中断 read,转而执行信号处理函数,当信号处理返回后,阻塞进程的 read 函数会返回 EINTR 错误。这时系统遇到了一个问题: 是重新开始这个 read 系统调用, 还是让 read 系统调用失败?最好的办法是人为重启被中断的系统调用。如下为 accept 失败时的处理:

while ( true ) {
    if ( accept(listenfd, (SA *)&cliaddr, &clilen) < 0 ){
        if ( errno == EINTR )
            continue;
        else
            err_sys( "accept error" )
    }
    ...
}

在 linux 或者 unix 环境中, errno 是一个十分重要的部分。在调用的函数出现问题的时候,我们可以通过
 errno 的值来确定出错的原因,这就会涉及到一个问题,那就是如何保证 errno 在多线程或者进程中安全?
我们希望在多线程或者进程中,每个线程或者进程都拥有自己独立和唯一的一个 errno ,这样就能够保证不会
有竞争条 件的出现。一般而言,编译器会自动保证 errno 的安全性,但是为了妥善期间,我们希望在写 
makefile 的时候把 _LIBC_REENTRANT 宏定义,比如我们在检查 <bits/errno.h> 文件中发现如下的定义:

C代码
# ifndef __ASSEMBLER__
/* Function to get address of global `errno' variable.  */
extern int *__errno_location (void) __THROW __attribute__ ((__const__));

#  if !defined _LIBC || defined _LIBC_REENTRANT
/* When using threads, errno is a per-thread value.  */
#   define errno (*__errno_location ())
#  endif
# endif /* !__ASSEMBLER__ */

也就是说,在没有定义 __LIBC 或者定义 _LIBC_REENTRANT 的时候, errno 是多线程/进程安全的。
一般而言, __ASSEMBLER__, _LIBC 和 _LIBC_REENTRANT 都不会被编译器定义,但是如果我们定义 _LIBC_REENTRANT 一次又何妨。

 

关于本段详细介绍,请看:附:信号中断 与 慢系统调用


wait 和 waitpid 函数

调用这两个函数来处理已终止的进程。

#include <sys/wait.h>

pid_t wait(int *status);
pid_t waitpid(pid_t pid, int *status, int options);
int waitid(idtype_t idtype, id_t id, siginfo_t *infop, int options); /* This is the glibc and POSIX interface. */

返回值:已终止进程的 ID 号,以及通过 statloc 指针返回的子进程的终止状态。

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

waitpid 函数的 pid 参数允许我们指定想要等待的进程 ID,值为 -1 时表示等待第一个终止的子进程。其次,options 参数允许我们指定附加选项。最常用的是 WNOHANG,它告知内核在没有已终止子进程时不要阻塞 waitpid 函数。

在前面的讨论我们知道,Unix 的信号一般是不排队的,所以 wait 有一定的局限性。比如说,有 5 个客户程序已经和服务器建立了连接,并且服务器进程为这 5 个客户各创建了一个子进程,当这 5 个客户程序同时 exit 时就会给服务器发送 5 个 FIN,这就导致服务器的 5 个子进程立即终止,最终导致同一时刻有 5 个 SIGCHLD 信号递交给父进程,而父进程却只执行了一次信号处理函数,也就是只回收了一个子进程的信息,别的 4 个子进程就变成了僵尸进程。正确的解决办法是调用 waitpid 并且指定 WNOHANG 选项,它告知 waitpid 在尚有未终止的子进程在运行时不要阻塞。

void sig_chld ( int signo )
{
    pid_t pid;
    int stat;
    while( (pid = waitpid(-1, &stat, WNOHANG)) > 0 );
    return;
}

accept 返回前连接中止

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

POSIX 指出此时服务端调用 accept 出错的返回的 errno 必须为 ECONNABORTED(software caused connection abort)。解决这种错误的办法就是忽略它,再次调用 accept 就行。


SIGPIPE 信号

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

看这种情况:服务端关闭客户子程序,就会对客户端发送一个 FIN,客户程序会响应一个 ACK。客户 TCP 接受到的这个 FIN 只是表示服务器进程已关闭了连接的服务器端,从而不再往该连接中发送任何数据而已。FIN 的接收并没有告知客户 TCP 服务器进程已经终止。这时的客户 TCP 还是可以向这个接收了 FIN 的套接字中写数据的,并且不会出错,但是,此时当服务器收到来自客户的数据时,既然先前打开的那个套接字进程已经终止,于是就响应一个 RST。客户 TCP 接收到 RST,此时再向这个套接字写数据就会收到 SIGPIPE 信号。

对于 SIGPIPE 如果没有特殊的事情要做,那么将信号处理办法直接置为 SIG_IGN,并假设后续的输出操作将捕捉 EPIPE 错误并终止。如果信号出现时需要采取特殊措施,那么就必须捕捉该信号,以便在信号处理函数中执行期望的操作。但是必须意识到,如果使用了多个套接字,该信号的递交无法告知是哪个套接字出的错。如果我们确实要知道是哪个 write 出了错,那么必须要么不理会该信号,要么从信号处理函数返回后再处理来自 write 的 EPIPE。


服务器主机崩溃

当服务器主机崩溃时,不会对客户的数据分节作出响应,此时客户 TCP 调用 read 所返回的错误是 ETIMEDOUT。然而如果某个中间路由器判定服务器主机已不可达,从而响应以一个 “destination unreachable” 的 ICMP 消息,那么所返回的错误是 EHOSTUNREACH 或 ENETUNREACH。

ICMP是(Internet Control Message Protocol)Internet 控制报文协议。它是 TCP/IP 协议族的一个子协议,用于在 IP 主机、路由器之间传递控制消息。控制消息是指网络通不通、主机是否可达、路由是否可用等网络本身的消息。这些控制消息虽然并不传输用户数据,但是对于用户数据的传递起着重要的作用。

如果我们不主动向它发送数据也想检测出服务器主机的崩溃,那么需要采用另外一种技术,即 SO_KEEPALIVE 套接字选项。后文详述。


服务器主机崩溃后重启

这个情况往往会导致客户 TCP 的 ECONNRESET 错误。


数据格式

当我们以文本串的格式传送字节时不用考虑主机字节序的不同。前提是客户和服务器主机具有相同的字符集。比喻一个 int 值为 “int  a = 123456”,如果是 utf8 字节编码,一个字符占用一个字节,那么作为文本串传送时的长度为 7 ,就像数组一样。如下:

void str_echo( int sockfd)
{
    long arg1, arg2;
    ssize_t n;
    char line[MAXLINE];

    if (read(sockfd, line, MAXLINE) == 0)
        return;    /*从客户端读取数据填充到 line 缓冲区*/    

    if (sscanf(line, "%ld%ld", &arg1, &arg2) == 2)    /*将 line 格式化到 arg1 arg2*/
        snprintf(line, sizeof(line), "%ld\n", arg1 + arg2); /*字符格式化相加的结果到 line*/
    else
        return;

    n = strlen(line);
    write(sockfd, line, n); /*以字符串格式回传结果*/
}

当以二进制结构传递时,如下:

void send_bin(int sockfd)
{
    long arg1 = 100;
    write(sockfd, &arg1, sizeof(arg1));
}

这时如果两端的主机字节序不一样,或者 long 的数据类型长度不一样,解析时往往会出错。采用这种格式通常在 RPC 软件包中,一般是显示定义所支持数据类型的二进制格式(位数、大小端字节序),并以这样的格式在客户与服务器之间传递所有数据。

原文链接:加载失败,请重新获取