unp - 客户/服务器程序设计范式

发布时间 2023-08-30 19:14:02作者: 某某人8265

网络服务常见知识点

unp中以一个 echo 服务为例

被中断的系统调用

重试 accept
 while (true) {
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd < 0 && errno == EINTR) {
        continue;
    }
    /* code */
}
重试 read write
{
again:
  while ((n = read(sockfd, ptr, BUFSIZ)) > 0)
    write(sockfd, ptr, n);
  if (n < 0 && errno == EINTR)  // EINTR
    goto again;
  else if (n < 0)
    err_sys("str_echo: read error");
}

ssize_t readn(int fd, void *buf, size_t count) { // 可使用 recv() 与 MSG_WAITALL 代替
  size_t nleft = count;
  ssize_t nread;
  char *bufp = (char *)buf;
  while (nleft > 0) {
    if ((nread = read(fd, bufp, nleft)) < 0) {
      if (errno == EINTR) { // 系统调用被捕获信号中断
        continue;
      }
      return -1;
    } else if (nread == 0) {
      return count - nleft;
    }
    bufp += nread;
    nleft -= nread;
  }
  return count;
}

ssize_t writen(int fd, const void *buf, size_t count) {
  size_t nleft = count;
  ssize_t nwrite;
  char *bufp = (char *)buf;
  while (nleft > 0) {
    if ((nwrite = write(fd, bufp, nleft)) < 0) {
      if (errno == EINTR) {
        continue;
      }
      return -1;
    } else if (nwrite == 0) {
      continue;
    }
    bufp += nwrite;
    nleft -= nwrite;
  }
  return count;
}

SIGCHLD 僵尸进程

子进程结束变成僵尸进程,并给父进程发送一个SIGHLD信号,该信号默认忽略,只有当父进程调用wait或waitpid后子进程资源才被回收。

一个进程中所有线程的信号处理操作相同,每个线程可以有独自的 sigmask,使用 sigaction 指定信号处理函数时可指定 sigmask。

void sigchld_handler(int sig) {
  // 此处不能单单使用 wait
  // 当多个SIGCHLD信号同时到达时信号处理函数只被调用一次
  // 未被处理的信号被忽略而不是排队
  // 使用循环反复处理僵尸进程
  // 使用 waitpid(-1, NULL, WHOHANG) 中-1表示所有子进程,WNOHANG表示当没有僵尸子进程时函数不阻塞
  while (waitpid(-1, NULL, WNOHANG) > 0) {
    /* code */
  }
}

{
  struct sigaction act;
  act.sa_handler = sigchld_handler;
  act.sa_flags = SA_RESTART; // SA_RESTART 指定syscall被中断后有系统恢复; SA_INTERRUPT 指定被打断后不恢复 不是每个版本都支持此宏
  sigemptyset(&act.sa_mask);
  sigaction(SIGCHLD, &act, nullptr /*old action*/);
}

accept 返回前连接中止

服务器调用 listen,客户端调用 connect,此时客户端发送一个 RST 包,服务端调用accept返回一个 EPROTO 错误,此时只要忽略并重试 accept 即可

服务器进程终止

// echo 服务客户端
while (fgets(buf, sizeof(buf), stdin) != NULL) {
  writen(sockfd, buf, strlen(buf)); // write to server
  if ((n = readn(sockfd, buf, sizeof(buf))) == 0) {
    printf("the other side has been closed.\n");
    exit(0);
  }
}

当服务端关闭并发送 FIN 时,客户端可能阻塞在fgets,只有当输入结束后调用read时才能得知对方已关闭。
客户端实际上在应对两个描述符——套接字和用户输入,它不能单独阻塞在这两个源的某一个上,而是应该通过 select epoll 阻塞在其中任何一个,一旦杀死服务器子进程,客户就会被告知收到FIN

使用 select 阻塞在任意一个
// 注意:select epoll 不能和stdio混用
// io复用只从read系统调用层面检测是否有数据可读,而不管stdio缓冲区内是否有数据未读出
void str_cli(int fp_in, int fp_out, int sockfd) {
  int maxfdp1;
  int stdineof{0};  // 是否已经读到文件尾
  fd_set rset;
  char buf[BUFSIZ];

  FD_ZERO(&rset);
  while (true) {
    if (stdineof == 0) {
      FD_SET(fp_in, &rset);
    }
    FD_SET(sockfd, &rset);
    maxfdp1 = max(fp_in, sockfd) + 1;
    select(maxfdp1, &rset, NULL, NULL, NULL);

    if (FD_ISSET(sockfd, &rset)) {
      if (read(sockfd, buf, BUFSIZ) == 0) {
        if (stdineof == 1) {  // 正常结束
          return;
        }
        printf("server terminated.\n");
        return;
      }
      write(fp_out, buf, strlen(buf)); // 输出
    }

    if (FD_ISSET(fp_in, &rset)) {
      if (read(fp_in, buf, BUFSIZ) == 0) { // 文件读到 EOF
        stdineof = 1;
        shutdown(sockfd, SHUT_WR);
        FD_CLR(fp_in, &rset);
        continue;
      }
      write(sockfd, buf, strlen(buf));
    }
  }
}

SIGPIPE 信号

客户端在读回任何数据前执行两次针对服务器的写操作,服务器子进程死亡后,第一次引发RST,第二次写时内核向该进程发送一个SIGPIPE信号。该信号默认终止进程,写操作会返回 EPIPE。

服务器关闭

使用 shutdown 函数可单方面关闭套接字,并指定读写

  1. 宕机,此时客户端将不断尝试,直至超时,返回 ETIMEOUT EHOSTUNREACH ENETUNREACH
  2. 崩溃后重启,客户端发送消息则会收到一个RST响应并返回一个 ECONNRESET 错误,客户应通过 SO_KEEPALIVE 等套接字选项保证用户不主动发数据也能检测出连接重置
  3. 服务器关机,unix关机时 init 进程发送SIGTERM信号给所有进程,等固定时间后发送 SIGKILL 信号强行终止

客户/服务器程序设计范式