Socket, Network programming

Table of Contents

1 Sockets简介

Berkeley sockets is an application programming interface (API) for Internet sockets and Unix domain sockets, used for inter-process communication (IPC). It is commonly implemented as a library of linkable modules. It originated with the 4.2BSD Unix released in 1983.

参考:
本文很多内容直接摘自《UNIX网络编程卷1:套接字联网API(第3版)》
The Linux Programming Interface, by Michael Kerrisk

1.1 套接字描述符

套接字是通信端点的抽象。和访问文件需要使用文件描述符类似,访问套接字需要使用套接字描述符。在UNIX系统中套接字描述符直接用文件描述符实现,处理文件描述符的函数(如read和write)可以直接处理套接字描述符。

1.1.1 创建套接字(socket函数)

用函数 socket 可以创建一个套接字描述符(类似于用open创建一个文件描述符)。

#include <sys/socket.h>
int socket(int domain, int type, int protocol);

                    /* Returns: file (socket) descriptor if OK, −1 on error */

下面将分别描述socket的各个参数。

Table 1: socket的第一个参数domain
Domain Communication between applications Address format Address structure
AF_UNIX on same host pathname sockaddr_un
AF_INET via IPv4 on hosts connected via an IPv4 network 32-bit IPv4 address + 16-bit port number sockaddr_in
AF_INET6 via IPv6 on hosts connected via an IPv6 network 128-bit IPv6 address + 16-bit port number sockaddr_in6
AF_UNSPEC unspecified unspecified unspecified

说明:AF表示Address Family。

Table 2: socket的第二个参数type
Type Description
SOCK_DGRAM fixed-length, connectionless, unreliable messages
SOCK_RAW datagram interface to IP (optional in POSIX.1)
SOCK_SEQPACKET fixed-length, sequenced, reliable, connection-oriented messages
SOCK_STREAM sequenced, reliable, bidirectional, connection-oriented byte streams

socket的第三个参数protocol通常为是零(表示按给定域和套接字类型选择默认协议)。
说明1:在AF_INET通信域中套接字类型SOCK_STREAM的默认协议是TCP;
说明2:在AF_INET通信域中套接字类型SOCK_DGARM的默认协议是UDP。

1.1.2 关闭套接字(shutdown函数)

函数 shutdown 用于关闭套接字,其原型为:

#include <sys/socket.h>
int shutdown(int sockfd, int howto);

                                    /* Returns 0 on success, or –1 on error */
Table 3: shutdown第二个参数howto
howto discription
SHUT_RD Close the reading half of the connection. Subsequent reads will return EOF. Data can still be written to the socket.
SHUT_WR Close the writing half of the connection. Once the peer application has read all outstanding data, it will see EOF. Subsequent writes to the local socket yield the SIGPIPE signal and an EPIPE error. Data written by the peer can still be read from the socket.
SHUT_RDWR Close both the read and the write halves of the connection.
1.1.2.1 close和shutdown的区别

处理文件描述符的函数 close 也可以用来关闭套接字。使用 shutdown 有下面两个优势:
(1) close将描述字的访问计数减1,仅当此计数为0时才真正关闭套接字。而用shutdown可以发起TCP的正常连接终止序列,而不管它的访问计数为多少。
(2) close终止数据传送的两个访问:读和写。TCP连接是全双工的,用shutdown可以只关闭“读方向”的连接或者只关闭“写方向”的连接。

2 TCP套接字编程

socket_tcp_functions.png

Figure 1: 基本TCP客户——服务器程序所用的套接字函数

2.1 listen函数

函数 listen 原型如下:

#include <sys/socket.h>
int listen(int sockfd, int backlog);

                                    /* Returns 0 on success, or –1 on error */

函数listen仅由TCP服务器调用,它有两个作用:
(1) 把主动套接字(active socket)转换为被动套接字(passive socket);用socket函数创建一个套接字时,默认为主动套接字(即将调用connect发起连接的客户端套接字),函数listen可以把未连接的套接字转换为“被动套接字”。在TCP状态转换图中, 调用listen函数将导致套接字从CLOSED状态转移到LISTEN状态。
(2) 指定了内核应该为相应套接字排队的最大连接个数(listen第二个参数)。

为了理解listen函数的第二个参数(套接字排队的最大连接个数),我们必须明白,对于给定的监听套接字,内核要维护两个队列:
(1) “未完成连接队列”,为每个这样的SYN分节开设一个条目:已经由客户发出并到达服务器,服务器正在等待完成相应的TCP三次握手过程。这些套接字都处于SYN_RCVD状态。
(2) “已完成连接队列”,为每个已完成TCP三次握手过程的客户开设一个条目。这些套接字都处于ESTABLISHED状态。

socket_listen_backlog.png

Figure 2: TCP为监听套接字维护两个队列

listen函数的第二个参数backlog规定了这两个队列中条目之和的最大值。

说明1:关于backlog的实际作用可能因系统的不同而不同,请查阅具体系统中的文档。
说明2:如果没有特别要求,你可以指定backlog为 SOMAXCONN (它的典型值可能为128)。

2.2 accept函数

函数 accept 原型如下:

#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

                      /* Returns file descriptor on success, or –1 on error */

函数accept由TCP服务器调用,用于从“已完成连接队列”的队列头返回下一个已完成的连接。如果“已完成连接队列”为空,那么accept会阻塞(假定套接字为默认的阻塞方式)直到一个请求到来。

如果accept成功,那么它的返回值是“由内核自动生成的一个全新套接字描述符,代表与所返回客户的TCP连接”。

2.2.1 “监听套接字”和“已连接套接字”的区别

函数accept的第一个参数称为“监听套接字”,而一个成功accept调用的返回值称为“已连接套接字”。

两者区别如下:
一个服务器通常仅仅创建一个“监听套接字”,它在该服务器的生命期内一直存在。内核自动为每个服务进程接受的客户连接创建一个“已连接套接字”(也就是说它的TCP三次握手过程已经完成),当服务器完成对某个给定客户的服务时,相应的“已连接套接字”就被关闭。

2.2.2 惊群问题(Thundering Herd)

惊群问题(Thundering herd problem):当某一时刻只有一个连接过来时,N个睡眠进程会被同时叫醒,但只有一个进程可获得连接。如果每次唤醒的进程数目太多,会影响一部分系统性能。

和accept函数相关的惊群问题在比较新的系统内核中已经解决。

参考:
UNIX网络编程第1卷:套接口API和XOpen传输接口API(第2版),27.6节
http://stackoverflow.com/questions/2213779/does-the-thundering-herd-problem-exist-on-linux-anymore

2.3 实例:迭代服务器

下面程序基于TCP实现一个简单的时间服务器(类似于:https://en.wikipedia.org/wiki/Daytime_Protocol)。
程序摘自:UNIX网络编程第1卷:套接口API和XOpen传输接口API(第2版),4.6节

// file mytimesrv.c: A time server
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <time.h>

int main(int argc, char **argv)
{
  int     listenfd, connfd;
  socklen_t len;
  struct sockaddr_in servaddr, cliaddr;
  char    buff[1024];
  time_t  ticks;

  if ((listenfd = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
    perror ("socket");
    exit (1);
  }

  memset(&servaddr, 0, sizeof (servaddr));
  servaddr.sin_family = AF_INET;
  servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
  servaddr.sin_port = htons(13000);                     /* daytime server */

  if (bind(listenfd, (struct sockaddr *) &servaddr, sizeof(servaddr)) < 0) {
    perror("bind");
    exit (1);
  }

  if (listen(listenfd, SOMAXCONN) < 0) {
    perror("listen");
    exit (1);
  }

  for ( ; ; ) {
    len = sizeof(cliaddr);
    if ((connfd = accept(listenfd, (struct sockaddr *) &cliaddr, &len)) < 0) {
      perror ("accept");
      exit (1);
    }
    printf("connection from %s, port %d\n",
           inet_ntop(AF_INET, &cliaddr.sin_addr, buff, sizeof(buff)),
           ntohs(cliaddr.sin_port));

    ticks = time(NULL);
    snprintf(buff, sizeof(buff), "%.24s\r\n", ctime(&ticks));

    write(connfd, buff, strlen(buff));
    close(connfd);
  }
}

编译完成并启动程序(服务器)后,在另一个终端中用程序(客户端)连接到服务器的TCP端口13000,如:

$ nc localhost 13000
Sat Dec 14 22:55:23 2013

而在服务器的终端中,则会显示类似下面的输出:

$ ./mytimesrv
connection from 127.0.0.1, port 51205

说明:这个服务器不是并发的,称为迭代服务器。由于它提供的服务是返回服务器的时间,这个操作在很短的时间内可以完成,函数accept很快又会被调用,这样当客户端较多时也能很快地获取下一个客户端的连接,所以采用迭代服务器也是可行的。但如果服务器提供的服务比较耗时,则不宜采用上面的编程方法,因为当客户端比较多时,不能让服务器长时间为某一个客户提供服务。这时,可以为每一个客户端连接fork出一个进程进行处理,或者使用select和poll等I/O复用方式或者其它方法。

2.4 实例:并发服务器(为客户请求fork进程)

下面实例中,为每个客户请求fork一个子进程来处理连接,处理完就退出。

/* 只演示了核心代码逻辑。比如没有wait子进程(防止子进程成为zombie process)等代码 */

for (;;) {
  cfd = accept(lfd, NULL, NULL);     /* Wait for connection */
  if (cfd == -1) {
    perror("accept");
    exit(EXIT_FAILURE);
  }

  /* Handle each client request in a new child process */
  switch (fork()) {         /* fork后,“监听套接字”和“已连接套接字”都会共享 */
  case -1:
    syslog(LOG_ERR, "Can't create child (%s)", strerror(errno));
    close(cfd);             /* Give up on this client */
    break;                  /* May be temporary; try next client */
  case 0:    /* Child */
    close(lfd);             /* “监听套接字”在父进程中处理,子进程中应该close它 */
    handleRequest(cfd);     /* 实际处理代码在handleRequest函数中 */
    exit(EXIT_SUCCESS);
  default:  /* Parent */
    close(cfd);             /* “已连接套接字”会在子进程中处理,父进程中应该close它(即引用计数减1) */
    break;                  /* Loop to accept next connection */
  }
 }

参考:
The Linux Programming Interface, 60.3 A Concurrent TCP echo Server
《UNIX网络编程卷1:套接字联网API(第3版)》 4.8节 并发服务器

3 UDP套接字编程

socket_udp_functions.png

Figure 3: 基本UDP客户——服务器程序所用的套接字函数

4 套接字选项

getsockopt 可以获取socket的相关选项,用 setsockopt 可以设置socket的相关选项。

#include <sys/socket.h>

int getsockopt(int sockfd, int level, int optname, void *optval, socklen_t *optlen);
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);

                                /* Both return 0 on success, or –1 on error */

其中,level可以是SOL_SOCKET/IPPROTO_IP/IPPROTO_TCP等。

参考:《UNIX网络编程第1卷:套接口API和XOpen传输接口API(第2版)》第7章

4.1 SO_REUSEADDR

SO_REUSEADDR 是一个常用的套接字选项,它有下面4个作用。

作用一:使服务器可以快速重启。 SO_REUSEADDR通知内核,如果端口忙,但TCP状态位于TIME_WAIT,可以重用端口。
这个情况可能这样碰到的:
(a) 启动一个监听服务器;
(b) 连接请求到达,派生一个子进程来处理这个客户;
(c) 监听服务器终止,但子进程继续为现有连接上的客户提供服务;
(d) 重启监听服务器。
默认地,监听服务器在步骤(d)时会出现bind失败,但如果该服务器在调用bind前设置了SO_REUSEADDR套接字选项,则bind将成功。

作用二:允许在同一个端口启动同一个服务器的多个实例,只要每个实例捆绑一个不同的本地IP地址即可。 这对于使用IP别名技术托管多个HTTP服务器的网点来说是很常见的。假设本地主机的主IP为198.69.10.2,不过它有两个别名:198.69.10.128和198.69.10.129。启动三个HTTP服务器。第一个绑定到INADDR_ANY和端口80。第二个HTTP服务器绑定到198.69.10.128和端口80会失败,除非调用bind前设置了SO_REUSEADDR选项。

作用三:允许单个进程捆绑同一端口到多个套接字上,只要每次捆绑指定不同的本地IP地址即可。

作用四:允许完全重复的捆绑:当一个IP地址和端口已绑定到某个套接字上时,如果传输协议支持,同样的IP地址和端口还可以捆绑到另一个套接字上。 一般来说,这个特性仅在支持多播的系统上才有,而且只对UDP套接口而言(TCP不支持多播)。

4.2 SO_KEEPALIVE

用SOL_SOCKET级别的SO_KEEPALIVE选项可以启动socket的keepalive特性。如果2小时(一般是默认值)内此套接字的任一方向都没有数据交换,TCP就自动给对方发一个保持存活探测分节(keepalive probe)。

下面是一个设置和检测keepalive选项的测试程序:

            /* --- begin of keepalive test program --- */

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>

int main(void);

int main()
{
   int s;
   int optval;
   socklen_t optlen = sizeof(optval);

   /* Create the socket */
   if((s = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP)) < 0) {
      perror("socket()");
      exit(EXIT_FAILURE);
   }

   /* Check the status for the keepalive option */
   if(getsockopt(s, SOL_SOCKET, SO_KEEPALIVE, &optval, &optlen) < 0) {
      perror("getsockopt()");
      close(s);
      exit(EXIT_FAILURE);
   }
   printf("SO_KEEPALIVE is %s\n", (optval ? "ON" : "OFF"));

   /* Set the option active */
   optval = 1;
   optlen = sizeof(optval);
   if(setsockopt(s, SOL_SOCKET, SO_KEEPALIVE, &optval, optlen) < 0) {
      perror("setsockopt()");
      close(s);
      exit(EXIT_FAILURE);
   }
   printf("SO_KEEPALIVE set on socket\n");

   /* Check the status again */
   if(getsockopt(s, SOL_SOCKET, SO_KEEPALIVE, &optval, &optlen) < 0) {
      perror("getsockopt()");
      close(s);
      exit(EXIT_FAILURE);
   }
   printf("SO_KEEPALIVE is %s\n", (optval ? "ON" : "OFF"));

   close(s);

   exit(EXIT_SUCCESS);
}

            /* ---  end of keepalive test program  --- */

注:上面程序摘自http://www.tldp.org/HOWTO/html_single/TCP-Keepalive-HOWTO/#examples

4.2.1 定制keepalive

由于系统的keepalive一般会设置为2小时,如果这不能满足需求,可以在应用程序中覆盖系统的设置。

IPPROTO_TCP级别的TCP_KEEPIDLE/TCP_KEEPINTVL/TCP_KEEPCNT对keepalive做更精细的控制。它们的说明如下:

tcp_keepalive_time(TCP_KEEPIDLE)
the interval between the last data packet sent (simple ACKs are not considered data) and the first keepalive probe; after the connection is marked to need keepalive, this counter is not used any further.
tcp_keepalive_intvl(TCP_KEEPINTVL)
the interval between subsequential keepalive probes, regardless of what the connection has exchanged in the meantime.
tcp_keepalive_probes(TCP_KEEPCNT)
the number of unacknowledged probes to send before considering the connection dead and notifying the application layer.

说明:并不是所有系统都支持上面的设置,如Solaris中就不支持在应用级别定制keepalive。

5 I/O复用

5.1 五种I/O模型概述

UNIX中有五种可用的I/O模型:

  • 阻塞I/O
  • 非阻塞I/O
  • I/O复用 (select和poll)
  • 信号驱动I/O (SIGIO)
  • 异步I/O (aio_系列函数)

5.1.1 阻塞I/O

阻塞I/O是最简单的模型,默认情况下,I/O系统调用都是阻塞的。这种方式的编程比较简单。

5.1.2 非阻塞I/O

阻塞I/O很可能由于数据没有准备好,而使进程长时间等待在read或write上。
非阻塞I/O使open/read/write等I/O操作函数不会阻塞,如果操作无法完成,就立即返回出错(设置errno为EAGIN或EWOULDBLOCK,在大多数现代系统中EAGIN和EWOULDBLOCK是相同的值)。

5.1.2.1 设置为非阻塞I/O

两种方法可以设置描述符为非阻塞I/O的方式:
方法一:如果调用open获得描述符,则可指定 O_NONBLOCK 标志;
方法二:对于已经打开的一个描述符,则可以调用fcntl,则该函数打开 O_NONBLOCK 文件状态标志。

5.1.2.2 轮询(浪费CPU资源)

在非阻塞I/O中,如果发现返回EAGIN或EWOULDBLOCK错误,可以隔一段时间再进行尝试。这种形式的循环称为 轮询(polling) 。但轮询中,我们很难知道需要等待多久再进行尝试,这可能 严重浪费CPU资源

5.1.3 信号驱动I/O(对于TCP套接字近乎无用)

信号驱动式I/O是指进程预先告知内核,使得当某个描述符上发生某事时,内核使用信号通知相关进程。它在历史上也曾被称为异步I/O。

针对一个套接使用信号驱动I/O要求进程执行以下3个步骤:
(1) 建立SIGIO信号的信号处理函数;
(2) 设置该套接字的属主,通常使用fcntl的F_SETOWN命令设置;
(3) 开启该套接字的信号驱动式I/O,通常通过使用fcntl的F_SETFL命令打开 O_ASYNC 标志完成。

在UDP上使用信号驱动式I/O是简单的。SIGIO信号在发生以下事件时产生:

  • 数据报到达套接字;
  • 套接字上发生异步错误。

不幸的是,信号驱动式I/O到于TCP套接字近乎无用。因为信号SIGIO产生得过于频繁,且它的出现并没有告诉我们发生了什么事件。 下面条件均会导致对于一个TCP套接字产生SIGIO信号(假设该套接字的信号驱动式I/O已经开启):

  • A connection request has completed on a listening socket
  • A disconnect request has been initiated
  • A disconnect request has completed
  • Half of a connection has been shut down
  • Data has arrived on a socket
  • Data has been sent from a socket (i.e., the output buffer has free space)
  • An asynchronous error occurred

参考:《UNIX网络编程卷1:套接字联网API(第3版)》第25章 信号驱动式I/O

5.1.4 异步I/O(应用程序设计比较复杂)

System V系统和BSD系统中对异步I/O有自己的实现,它们都有各自的局限,这里讨论的是POSIX异步I/O。

使用POSIX异步I/O接口,多少有些复杂:

  • 每个异步操作有3处可能产生错误的地方:一处是操作提交的部分,一处是操作本身的结果,还有一处是用于决定异步操作状态的函数中。
  • 与传统方法相比,它涉及大量的额外设置和处理规则。
  • 从错误中恢复可能会比较困难。举例来说,如果提交了多个异步写操作,其中一个失败了,下一步我们应该怎么做?如果这些写操作是相关的,那么可能还需要撤销所有成功的写操作。

参考:《UNIX环境高级编程(第3版)》14.5 异步I/O

5.1.5 I/O复用(单进程设计中应用最广)

对于单进程设计,要提供较好的并发度,I/O复用是较好的选择。后文将介绍它。

5.1.6 五种I/O模型比较

如后面给出的例子所述, 一个输入操作一般可以分为两个不同的阶段:(1) 等待数据准备好;(2) 将数据从内核缓冲区拷贝到应用程序的缓冲区。

下面将以UDP而不是TCP为例来介绍各个I/O模型的特点。采用UDP作为例子的原因是在UDP中“数据准备好”的概念比较简单:整个数据报是否已经接收。这些都是通用的I/O模型,不一定要用于socket。

socket_block_IO.png

Figure 4: Blocking I/O model

socket_nonblock_IO.png

Figure 5: Nonblocking I/O model

socket_IO_multiplex.png

Figure 6: I/O multiplexing model

socket_IO_signal_driven.png

Figure 7: Signal-Driven I/O model

socket_asynchronous_IO.png

Figure 8: Asynchronous I/O model

在前面介绍的五种I/O模型中,前四种模型的主要区别都在第一阶段(等待数据),第二阶段(将数据从内核拷贝到用户空间)基本相同。而异步I/O模型处理的两个阶段都不同于前四个模型。

socket_IO_models.png

Figure 9: Comparison of the five I/O models

5.2 I/O复用——select函数

不用多进程(或多线程)时,阻塞I/O无法同时处理多个请求,而对于非阻塞I/O,其轮询操作会浪费CPU资源。

select 函数可以允许应用程序同时在多个文件描述符上等待输入的到达(或者等待输出的结束)。

基本用法是: 先构造一个我们感兴趣的描述符的列表,然后调用函数select,直到这些描述符中的一个已经准备好进行I/O时,select才返回,返回时内核告诉我们已准备好的描述符总数量以及哪些描述符已准备好。

select函数的原型为:

#include <sys/select.h>

int select(int maxfdp1,                     /* 最大文件描述符编号值加1 */
           fd_set *restrict readfds,        /* 既是输入参数,又是输出参数 */
           fd_set *restrict writefds,       /* 既是输入参数,又是输出参数 */
           fd_set *restrict exceptfds,      /* 既是输入参数,又是输出参数 */
           struct timeval *restrict tvptr); /* 愿意等待的时间(也可能是输出参数) */

           /* Returns: count of ready descriptors, 0 on timeout, −1 on error */

5.2.1 select的参数说明

下面将从后往前介绍select每个参数的含义。

select的最后一个参数tvptr用来指定愿意等待的时间,timeval结构的精度可控制为“秒数”和“微秒数”。

struct timeval {
  long tv_sec;    /* seconds */
  long tv_usec;   /* microseconds */
}
Table 4: select最后一个参数可控制愿意等待select的时间
tvptr 描述
tvptr == NULL 永远等待。如果捕捉到一个信号则中断此无限期等待。
tvptr->tv_sec == 0 && tvptr->tv_usec == 0 根本不等待。检查描述符后立即返回,这称为轮询。
tvptr->tv_sec != 0 || tvptr->tv_usec != 0 等待指定的秒数和微秒数。时间未到时,也可被信号中断。

说明1: 一个描述符阻塞与否并不影响select是否阻塞。
说明2:POSIX.1允许在select实现中修改tvptr结构中的值,所以在select返回后,不要指望该结构仍旧保持调用select之前它所包含的值。FreeBSD 5.2.1、Mac OS X 10.3和Solaris 9都保持该结构中的值不变。但是Linux 2.4.22中,若在该时间值尚未超过时select就返回,那么将用“余留时间值”更新该结构。

select的中间3个参数readfds/writefds/exceptfds是指向描述符集的指针。这3个描述符说明了我们关心的可读、可写或处于异常条件的描述符集合。select会修改由指针readfds/writefds/exceptfds所指向的描述符集。所以, readfds/writefds/exceptfds既是输入参数又是输出参数(或称“值—结果”参数),调用select时,我们指定它为所关心的描述符,当select返回时,它将指示哪些描述符已就绪。

fd_set类型可以由具体的实现来决定,可以认为fd_set变量是一个很大的字节数组 ,你可以使用下面函数(或宏)来设置或测试它。

Table 5: fd_set的相关函数(或宏)
fd_set操作函数 描述
FD_ZERO 将fd_set变量的所有位设置为0
FD_SET 开启fd_set变量中的一位
FD_CLR 消除fd_set变量中的一位
FD_ISSET 测试fd_set变量中的指定位是否打开

如果readfds/writefds/exceptfds指定为空指针表示对相应的条件并不关心。如果3个指针都是NULL,则select提供了比sleep(仅整数秒)更精确的定时器。

select的第一个参数maxfdp1的意思是“最大文件描述符编号值加1”。它有两种常见的设置:

  • 在3个描述符集中找出最大的描述符编号值,然后加1(因为描述符从0开始)。
  • 设置为FD_SETSIZE,一般地,它的典型值为1024。

说明: 通过指定所关注的最大描述符的个数,内核就只需要在些范围内寻找打开的位。 显然,如果我们关心的描述符很少,maxfdp1设置为FD_SETSIZE时可能会影响效率。

参考:
《UNIX环境高级编程(第3版)》14.4节
《UNIX网络编程卷1:套接字联网API(第3版)》6.3节

5.2.2 select简单实例

下面是select的实例。为简单起见,例子中仅设置对一个描述符(标准输入)感兴趣。

#include <stdio.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>

int main(void) {
  fd_set readfds;
  struct timeval tv;
  int retval;

  FD_ZERO(&readfds);
  FD_SET(0, &readfds);  /* 把“标准输入”放入“对可读状态感兴趣的描述符集”中 */
  /* 如果还对其它描述符的可读状态感兴趣,可以把它加入到readfds中:FD_SET(xx, &readfds); */

  tv.tv_sec = 3;        /* select最多等待3秒 */
  tv.tv_usec = 0;

  retval = select(1, &readfds, NULL, NULL, &tv);

  switch (retval) {
  case -1:
    perror("select()");
  case 0:
    printf("Timeout, no data within 3 seconds.\n");
    break;
  default:
    printf("Data is available now.\n");
    /* FD_ISSET(0, &readfds) will be true. */
  }

  return 0;
}

上面程序测试如下:

$ echo abc | ./a.out
Data is available now.
$ ./a.out     # 3秒内不进行任何操作
Timeout, no data within 3 seconds.

select的其它例子(更接近于实际应用):http://www.lowtek.com/sockets/select.html

5.2.3 select版echo服务器

下面是select版本的echo服务器。主要代码参考:http://www.gnu.org/software/libc/manual/html_node/Server-Example.html

#include <stdio.h>
#include <errno.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <string.h>

#define PORT    5555
#define MAXMSG  512

int make_socket (uint16_t port) {
  int sock;
  struct sockaddr_in name;

  sock = socket (PF_INET, SOCK_STREAM, 0);
  if (sock < 0) {
    perror ("socket");
    exit (EXIT_FAILURE);
  }

  name.sin_family = AF_INET;
  name.sin_port = htons (port);
  name.sin_addr.s_addr = htonl (INADDR_ANY);
  if (bind (sock, (struct sockaddr *) &name, sizeof (name)) < 0) {
    perror ("bind");
    exit (EXIT_FAILURE);
  }

  return sock;
}

int do_it (int filedes) {
  char buffer[MAXMSG] = { 0 };
  int nbytes;

  nbytes = read (filedes, buffer, MAXMSG);
  if (nbytes < 0) {
    /* Read error. */
    perror ("read");
    exit (EXIT_FAILURE);
  }
  else if (nbytes == 0)
    /* End-of-file. */
    return -1;
  else {
    /* Data read. */
    fprintf (stderr, "Server: got message: `%s'\n", buffer);

    /* Just echo the input string back to the client  */
    write(filedes, buffer, strlen(buffer));

    return 0;
  }
}

int main (void) {
  int sock;
  fd_set active_fd_set, read_fd_set;
  int fd;
  struct sockaddr_in clientname;
  socklen_t size;

  /* Create the socket and set it up to accept connections. */
  sock = make_socket (PORT);
  if (listen (sock, 1) < 0) {
    perror ("listen");
    exit (EXIT_FAILURE);
  }

  /* Initialize the set of active sockets. */
  FD_ZERO (&active_fd_set);
  FD_SET (sock, &active_fd_set);
  /* 把sock加入到select监视的可读描述符中,
     当sock可读时(有新客户连接)select会返回,这时可以用accept获取它 */

  while (1) {
    /* select 的第2,3,4,5个参数都是“输入-输出”参数,可能被select修改,
       即使监视的描述符不变(这里监视的描述符是变化的),也要在每次调用
       select前要重新设置它们。
    */
    read_fd_set = active_fd_set;

    /* Block until input arrives on one or more active sockets. */
    if (select (FD_SETSIZE, &read_fd_set, NULL, NULL, NULL) < 0) {
      perror ("select");
      exit (EXIT_FAILURE);
    }

    /* Service all the sockets with input pending. */
    for (fd = 0; fd < FD_SETSIZE; ++fd) {
      if (FD_ISSET (fd, &read_fd_set)) {
        if (fd == sock) {
          /* Connection request on original socket. */
          int new;
          size = sizeof (clientname);
          new = accept (sock, (struct sockaddr *) &clientname, &size);
          if (new < 0) {
            perror ("accept");
            exit (EXIT_FAILURE);
          }

          fprintf (stdout,
                   "Server: connect from host %s, port %hu.\n",
                   inet_ntoa(clientname.sin_addr),
                   ntohs(clientname.sin_port));
          /* 把“新的客户连接加入到select监视的可读描述符中 */
          FD_SET (new, &active_fd_set);
        } else {
          /* Data arriving on an already-connected socket. */
          if (do_it (fd) < 0) {
            close (fd);
            FD_CLR (fd, &active_fd_set);
          }
        }
      }
    }
  }
}

5.2.4 select的缺点

select在下面的缺点:

  • 最大的并发数限制。进程打开的文件描述符是有限制的(为FD_SETSIZE,一般为1024)。
  • 从select返回后,应用程序要 遍历描述符集合才能知道具体哪个描述符已经准备好了 ,当监视的描述符多时,会影响效率。即I/O效率随着监视描述符的数目增加而线性下降。
  • select会修改它的参数readfds/writefds/exceptfds,在后续调用select时,要监控同样的描述符,不得不重新初始化参数(可先备份,在再次调用select前用memcpy恢复这些参数),这会影响效率。

参考:http://stackoverflow.com/questions/970979/what-are-the-differences-between-poll-and-select

5.3 I/O复用——poll函数

poll函数和select类似,也能实现I/O复用。poll函数的原型如下:

#include <poll.h>
int poll(struct pollfd fdarray[], nfds_t nfds, int timeout);

           /* Returns: count of ready descriptors, 0 on timeout, −1 on error */

和select不同之处在于,表达描述符集合的方式不同,poll使用pollfd结构,而不是select的fd_set结构。

struct pollfd {
  int fd;          /* file descriptor to check, or < 0 to ignore */
  short events;    /* (输入参数) events of interest on fd */
  short revents;   /* (输出参数) events that occurred on fd */
};

5.3.1 select和poll的比较

poll没有并发数的限制。 因为数组fdarray的大小由参数nfds指定,它由应用程序指定,可以是非常大。
poll的参数设计比select更合理,它把“输入参数”和“输出参数”分开了(在pollfd中分别属于不同的成员),这样不用像select那样在后续的调用前需要初始化它的参数readfds/writefds/exceptfds。

5.4 I/O复用——epoll函数(仅Linux中)

Linux epoll (event poll) API is used to monitor multiple file descriptors to see if they are ready for I/O.

5.5 I/O复用——kqueue函数(FreeBSD类系统中)

Kqueue is a scalable event notification interface introduced in FreeBSD 4.1, also supported in NetBSD, OpenBSD, DragonflyBSD, and OS X. Kqueue was originally authored in 2000 by Jonathan Lemon, then involved with the FreeBSD Core Team.

6 经典服务器设计范式

前面介绍的迭代服务器和并发服务器(为客户请求fork进程)的实际可应用范围很少。
为了提供比较好的并发性,有两种常见服务器设计:一是单进程中使用I/O复用或信号驱动I/O等模型;二是预先创建“进程池”或“线程池”。这里将重点介绍预先创建“进程池”或“线程池”的方法。

socket_prefork_prethread.png

Figure 10: 一些服务器设计范式的测试比较(摘自:《UNIX网络编程,卷1:套接字联网API(第3版)》,第30章)

说明: “进程池”或“线程池”的方法都是比较“经典”的并发服务器设计范式,如果对服务器的并发性要求非常高,则它们都无法胜任。这时,我们可以采用Reactor模式(底层使用的是I/O复用,如select/poll等)或Proactor模式(底层使用的是异步I/O,如Windows中的完成端口或UNIX中aio_*()系列函数)来设计服务器。

参考:
《UNIX网络编程,卷1:套接字联网API(第3版)》,第30章
The Linux Programming Interface, 60.4 Other Concurrent Server Designs

6.1 实例:并发服务器(预先创建线程池)

下面是一个并发服务器(其功能是返回服务器的当前时间)实例,可以通过命令行参数指定预先启动的线程数。

// file myserv.c: A time server
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <assert.h>

#include <unistd.h>
#include <pthread.h>
#include <semaphore.h>

typedef struct _worker_info {    /* 每个线程都关联这样一个结构 */
  int connfd;             /* 保存“已连接套接字”(由accept返回)
                             初始值为-1,当工作线程在该套接字上的工作完成后也设为-1
                             它的可能值为-1(当前线程闲),或大于0的值(当前线程忙)
                             对它的访问要加锁 */
  pthread_mutex_t lock;   /* 互斥量,用于对connfd的访问加锁 */
  pthread_cond_t cond;    /* 条件变量,用于管理空闲的线程(让它阻塞,或者唤醒它) */

  /* other information */
  pthread_t tid;
} worker_info;


worker_info workers[128];     /* 假设线程数不会大于128 */
int threads_num = 3;          /* 默认的线程数量 */

sem_t sem;                    /* 这个信号量管理可用的线程数 */

void * thread_fn(void *arg);
int request_worker();

int main(int argc, char **argv) {
  short int port;

  if (argc == 2) {
    port = atoi(argv[1]);
  } else if (argc == 3) {
    port = atoi(argv[1]);
    threads_num = atoi(argv[2]);
  } else {
    fprintf(stderr, "%s", "Usage: myserv port [thread_num]\n"); exit(1);
  }

  /* 第1步:创建socket,绑定、监听到指定端口 */
  int listenfd, connfd;
  struct sockaddr_in servaddr;

  if ((listenfd = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
    perror("socket"); exit(1);
  }

  memset(&servaddr, 0, sizeof(servaddr));
  servaddr.sin_family = AF_INET;
  servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
  servaddr.sin_port = htons(port);

  if (bind(listenfd, (struct sockaddr *) &servaddr, sizeof(servaddr)) < 0) {
    perror("bind"); exit (1);
  }

  if (listen(listenfd, SOMAXCONN) < 0) {
    perror("listen"); exit (1);
  }

  /* 第2步:创建线程池 */
  int i;
  for (i=0; i < threads_num; i++) {

    /* 准备好传递给线程的参数 */
    workers[i].connfd = -1;
    pthread_mutex_init(&workers[i].lock, NULL);
    pthread_cond_init(&workers[i].cond, NULL);

    /* 创建线程 */
    if (pthread_create(&workers[i].tid, NULL, thread_fn, (void *)(workers + i)) != 0) {
      perror("pthread_create"); exit(1);
    }

  }

  sem_init(&sem, 0, threads_num);      /* 初始信号量为所创建的线程数 */

  /* 第3步:为每个“客户连接”分配一个空闲线程进行处理 */
  int slot;
  int clientfd;
  for( ; ; ) {

    slot = request_worker();           /* 寻找空闲的线程,找不到会阻塞 */

    if ((clientfd = accept(listenfd, NULL, NULL)) < 0) {
      perror("accept"); exit(1);
    }

    pthread_mutex_lock(&workers[slot].lock);
    workers[slot].connfd = clientfd;
    pthread_mutex_unlock(&workers[slot].lock);

    /* 唤醒这个空闲的线程 */
    pthread_cond_signal(&workers[slot].cond);
  }

  return 0;
}

/* 在线程池中寻找一个空闲线程,当没有空闲线程时会阻塞 */
int request_worker() {
  sem_wait(&sem);                    /* 仅当有空闲线程时,才会返回 */

  int i;
  int found = 0;
  for (i=0; i < threads_num; i++) {
    if (workers[i].connfd == -1) {
      found = 1;
      break;
    }
  }
  assert(found == 1);
  return i;
}

/* 业务逻辑(对socket的读写操作)都在这里 */
void doit(int sockfd) {
  char    buff[1024];
  time_t  ticks;

  ticks = time(NULL);
  snprintf(buff, sizeof(buff), "%.24s\r\n", ctime(&ticks));
  write(sockfd, buff, strlen(buff));

  close(sockfd);

  /*  sleep(1);   */
  fprintf(stdout, "server log: do work.\n");
}

/* 线程启动函数 */
void * thread_fn(void *arg) {
  worker_info *info = (worker_info *)arg;

  for( ; ; ) {
    pthread_mutex_lock(&info->lock);
    while(info->connfd == -1) {
      pthread_cond_wait(&info->cond, &info->lock);
      /* pthread_cond_wait有3个作用:
         (1) 解锁互斥量info->lock
         (2) 阻塞线程直到另外的线程通过条件变量info->cond唤醒它
         (3) 再次对互斥量info->lock加锁 */

    }

    doit(info->connfd);        /* Do some work with this connection */

    info->connfd = -1;
    pthread_mutex_unlock(&info->lock);

    sem_post(&sem);            /* 信号量加1(空闲线程多了1个) */
  }
}

上面的实现中,当工作线程没事可做时,会阻塞在函数pthread_cond_wait上;当主线程给它分配了工作(把connfd从-1修改为“已连接套接字”)后,就会唤醒它。

注:对于上面的服务器,如果同时连接的客户端非常多,导致没有空闲的线程时(由于时间服务器的处理非常快,为模拟这种场景我们可以在函数doit中调用sleep),会出现什么情况呢?主线程会阻塞在函数request_worker中的sem_wait上,这会导致accept迟迟得不到调用,客户端可能超时。

7 Unix域协议

Unix域协议并不是一个实际的协议族,而是在单个主机上执行客户/服务器通信的一种方法,所用API与在不同主机上执行客户/服务器通信所用的API(套接字API)相同。Unix域协议可视为进程间通信(IPC)方法之一。

Unix域提供两类套接字:字节流套接字(类似TCP)和数据报套接字(类似UDP)。

使用Unix域套接字的理由有3个:
(1) 在源自Berkeley的实现中,Unix域套接字往往比通信两端位于同一主机的TCP套接字快出一倍。X Window System使用了Unix域套接字的这个优势,如果发现客户和服务器在一个主机上,则会使用Unix域字节流连接,否则使用TCP连接。
(2) Unix域套接字可用于在同一个主机上的不同进程间传递描述字。
(3) Unix域套接字较新的实现把客户的凭证(用户ID和组ID)提供给服务器,从而能够提供额外的安全检查措施。

注:POSIX把Unix域协议重新命名为“本地IPC”,以消除它对于Unix操作系统的依赖。常值AF_UNIX变为AF_LOCAL。尽管如此,我们依然使用“Unix域”这个称谓,因为这已成为它约定俗成的名字,与支撑它的操作系统无关。


Author: cig01

Created: <2013-12-14 Sat 00:00>

Last updated: <2018-02-14 Wed 12:16>

Creator: Emacs 25.3.1 (Org mode 9.1.4)