之前学习过使用多进程创建服务器,现在介绍建立多进程服务器的另一种方法:I/O 复用技术。

什么是复用?

拿一个来看一下,下面是我们小时候经常玩的纸杯通话,加入有 3 个人对话的话,我们就需要 6 个杯子来实现这个对话系统。

§ select 函数实现 I/O 复用实现服务器 - 图1

而我们如果引入复用技术,这个时候就可以在只有 3 个杯子的情况下,1 个人对话而另外 2 个人同时听见,这个时候便会节省资源。

§ select 函数实现 I/O 复用实现服务器 - 图2

现在考虑一下上述复用系统相比于前一种的优点和缺点:

优点:

  • 减少连线长度
  • 减少纸杯个数

缺点:

  • 保密性不强:不能实现 3 人中的 2 人单独通话
  • 不能同时说话,因为现在说话和听都是一根线,所以只能一个人说完,另一个人说

在《自顶向下-计算机网络一书中》,第 1 章介绍时分电路和频分电路,而这个就是生活中最常用到的复用技术。

复用技术在服务器端的应用

纸杯电话系统引入复用技术后,可以减少纸杯书和连线长度。同样,服务器端引入复用技术后可以减少所需进程数,下面是两种模型的对比。

§ select 函数实现 I/O 复用实现服务器 - 图3

相对比与多进程服务器,我们必须先让客户端请求一下服务,然后建立连接。拿一个例子来看,就像 1 个老师回答 10 个同学的问题,教师必须先确定是否有学生举手,然后对举手的学生回答问题。

Linux 实现 I/O 复用服务器端

下面是通过 select 函数实现 I/O 复用服务器端,之前已经给出了 select 函数的所有说明,下面只需要利用 select 函数实现服务器端。下面是基于 I/O 复用的回声客户端。

  1. #include <stdio.h>
  2. #include <stdlib.h>
  3. #include <string.h>
  4. #include <unistd.h>
  5. #include <arpa/inet.h>
  6. #include <sys/socket.h>
  7. #include <sys/time.h>
  8. #include <sys/select.h>
  9. #define BUF_SIZE 100
  10. void error_handling(char *buf);
  11. int main(int argc, char *argv[]) {
  12. // 1.检查输入格式
  13. if (argc != 2) {
  14. printf("Usage %s <port> \n", argv[0]);
  15. exit(1);
  16. }
  17. // 2.设定套接字
  18. int serv_sock, clnt_sock;
  19. struct sockaddr_in serv_adr, clnt_adr;
  20. serv_sock = socket(PF_INET, SOCK_STREAM, 0);
  21. memset(&serv_adr, 0, sizeof(serv_adr));
  22. serv_adr.sin_family = AF_INET;
  23. serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
  24. serv_adr.sin_port = htons(atoi(argv[1]));
  25. // 3.绑定套接字
  26. if (bind(serv_sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)) == -1)
  27. error_handling("bind() error");
  28. // 4.进入监听状态
  29. if (listen(serv_sock, 5) == -1)
  30. error_handling("listen() error");
  31. // 5.初始化 fd_set 数组
  32. fd_set reads, cpy_reads;
  33. FD_ZERO(&reads);
  34. FD_SET(serv_sock, &reads); // 监听文件描述符 serv_sock,即套接字
  35. fd_max = serv_sock;
  36. struct timeval timeout;
  37. socklen_t adr_sz;
  38. int fd_max, str_len, fd_num, i;
  39. char buf[BUF_SIZE];
  40. while(1) {
  41. cpy_reads = reads; // 注意:需要保留一个最初的 reads,这个时候只监听 hServSock,而没有客户端套接字
  42. timeout.tv_sec = 5;
  43. timeout.tv_usec = 5000;
  44. // 6.select 调用出错
  45. if ( (fd_num=select(fd_max+1, &cpy_reads, 0, 0, &timeout)) == -1)
  46. break;
  47. // 7.select 超时
  48. if (fd_num==0)
  49. continue;
  50. for (i=0; i<fd_max+1; i++) {
  51. // 8.循环找到发生变化的文件描述符
  52. if (FD_ISSET(i, &cpy_reads)) {
  53. // 8.1.如果发生变化的套接字是监听的服务器端套接字 serv_sock,客户端请求建立连接
  54. if (i == serv_sock) {
  55. adr_sz = sizeof(clnt_adr);
  56. clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_sz);
  57. FD_SET(clnt_sock, &reads); // 对新的客户端,该客户端套接字监听
  58. if (if_max < clnt_sock)
  59. fd_max = clnt_sock;
  60. printf("connected client: %d \n", clnt_sock);
  61. }
  62. // 8.2.发生变化的套接字并非服务器端套接字,而是客户端套接字 clnt_sock。即有要接
  63. // 收的数据执行else语句。但此时需要确认接收的数据是字符串还是代表断开连接的EOF
  64. else {
  65. str_len = read(i, buf, BUF_SIZE);
  66. if (str_len == 0) { // 接收的数据为EOF时关闭套接字,并删除reads相应信息
  67. FD_CLR(i, &reads);
  68. close(i);
  69. printf("closed client: %d \n", i);
  70. }
  71. else { // 收到客户端字符串,则将该字符串再发给客户端,回声
  72. write(i, buf, str_len);
  73. }
  74. }
  75. }
  76. }
  77. }
  78. close (serv_sock);
  79. return 0;
  80. }
  81. void error_handling(char *buf) {
  82. fputs(buf, stderr);
  83. fputc('\n', stderr);
  84. exit(1);
  85. }

配合上之前学习的多进程并发服务器和今天学习的 I/O 服用服务器,可以看到并发服务器最终实现的效果如下图所示:

§ select 函数实现 I/O 复用实现服务器 - 图4

其中,

  • serv_sock 配合 accept 函数与客户端连接,分配和该客户端的连接通道 clnt_sock
  • 使用 clnt_sock 通信

多进程并发服务器为每个 clnt_sock 分配一个子进程来通信,而 I/O 复用服务器则是通过父进程循环判断子进程描述符从而实现与客户端通信。

Windows 实现 I/O 复用服务器端

Windows 同样提供 select 函数,而且所有参数与 Linux 的 select 函数完全相同。只不过 Windows 平台 select 函数的第一个参数是为了保持与(包括 Linux 的)UNIX 系列操作系统的兼容性而添加的,并没有特殊意义。

select 函数

select 函数原型如下

  1. #include <winsock2.h>
  2. // select函数调用成功时返回0,失败时返回 -1
  3. int select(int nfds,
  4. fd_set *readfds, fd_set *writefds, fd_set *excepfds,
  5. const struct timeval *timeout);

这个函数的返回值、参数顺序及含义与之前 Linux 的 select 函数完全相同,这里省略。下面给出 timeval 结构体定义。

  1. typedef struct timeval {
  2. long tv_sec; // seconds
  3. long tv_usec; // microseconds
  4. } TIMEVAL;

基本结构与之前 Linux 中定义的相同,但是 Windows 这里使用的是 typedef 声明。接下来观察 fd_set 结构体,Windows 中实现时需要注意的地方就在于此,因为 Windows 的 fd_set 并不像 Linux 那样使用了位数组。

  1. typedef struct fd_set {
  2. u_int fd_count; // 用于套接字句柄数,即有多少个套接字
  3. SOCKET fd_array[FD_SETSIZE]; // 保存套接字句柄,套接字有哪些
  4. } fd_set;

可以看到,上述的 fd_set 是由成员 fd_count 和 fd_array 构成的,这里我们思考一下为什么 Linux 和 Windows 的 fd_set 有这样的区别。

  • Linux 的文件描述符从 0 开始递增,套接字取得是值最小的文件描述符,所以很容易找到文件描述符数量和套接字关系
  • Windows 的套接字句柄并不是从 0 开始,所以句柄的值之间是没有什么规律的,所以需要使用 fd_count 记录套接字的数量,并且使用 fd_array 记录套接字。

微软为了保证兼容性,保留了处理 fd_set 结构体的 FD_XXX 型的 4 个宏,其功能、名称及使用方法和 Linux 完全相同。

基于 Windows 实现 I/O 复用服务器端

下面是 Windows 的 I/O 复用服务器端实现代码

  1. #include <stdio.h>
  2. #include <stdlib.h>
  3. #include <string.h>
  4. #include <winsock2.h>
  5. #define BUF_SIZE 1024
  6. void ErrorHandling(char *message);
  7. int main(int argc, char *argv[]) {
  8. // 1.检验输入
  9. if (argc != 2) {
  10. printf("Usage : %d <port> \n", argv[0]);
  11. exit(1);
  12. }
  13. // 2.指明 Winsock 版本
  14. WSADATA wsaData;
  15. if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) // 2.2 版本
  16. ErrorHandling("WSAStartup() error!");
  17. // 3.初始化套接字描述符
  18. SOCKET hServSock, hClntSock;
  19. SOCKADDR_IN servAdr, clntAdr;
  20. hServSock = socket(PF_INET, SOCK_STREAM, 0);
  21. memset(&servAdr, 0, sizeof(servAdr));
  22. servAdr.sin_family = AF_INET;
  23. servAdr.sin_addr.s_addr = htonl(INADDR_ANY);
  24. servAdr.sin_port = htons(atoi(argv[1]);)
  25. // 4.绑定套接字
  26. if (bind(hServSock, (SOCKADDR*)&servAdr, sizeof(servAdr)) == SOCKET_ERROR)
  27. ErrorHandling("bind() error");
  28. // 5.监听状态
  29. if (listen(hServSock, 5) == SOCKET_ERROR)
  30. ErrorHandling("listen() error");
  31. // 6.初始化 fd_set 数组
  32. fd_set reads, cpyReads;
  33. FD_ZERO(&reads);
  34. FD_SET(hServSock, &reads); // 监听 hServSock 套接字,查看是否有客户端连接
  35. TIMEVAL timeout;
  36. int adrSz;
  37. int strLen, fdNum, i;
  38. char buf[BUF_SIZE];
  39. while(1) {
  40. cpyReads = reads; // 注意:需要保留一个最初的 reads,这个时候只监听 hServSock,而没有客户端套接字
  41. timeout.tv_sec = 5;
  42. timeout.tv_usec = 5000;
  43. if ( (fdNum=select(0, &cpyReads, 0, 0, &timeout)) == SOCKET_ERROR) // select 函数出错
  44. break;
  45. if (fdNum == 0)
  46. continue;
  47. for (i=0; i<reads.fd_count; i++) {
  48. // 7.如果套接字发生了变化
  49. if (FD_ISSET(reads.fd_array[i], &cpyReads)) {
  50. // 7.1.如果是服务器套接字,则说明有新的客户端请求连接
  51. if (reads.fd_array[i] == hServSock) {
  52. adrSz = sizeof(clntAdr);
  53. hClntSock = accept(hServSock, (SOCKADDR*)&clntAdr, &adrSz); // 为该客户端分配套接字 hClntSock
  54. FD_SET(hClntSock, &reads); // 监听该套接字
  55. printf("connected client: %d \n", hClntSock);
  56. }
  57. // 7.2如果是某个客户端套接字 hClntSock,则处理请求
  58. else {
  59. streLen = recv(reads.fd_array[i], buf, BUF_SIZE-1, 0);
  60. if (strLen == 0) { // 7.2.1.对于EOF
  61. FD_CLR(reads.fd_array[i], &reads); // 不再接听该套接字
  62. closesocket(cpyReads.fd_array[i]); // 关闭该客户端对应套接字
  63. printf("closed client: %d \n", cpyReads.fd_array[i]);
  64. }
  65. else {
  66. send(reads.fd_array[i], buf, strLen, 0); // 7.2.2.回声服务器
  67. }
  68. }
  69. }
  70. }
  71. }
  72. closesocket(hServSock);
  73. WSACleanup();
  74. return 0;
  75. }
  76. void ErrorHandling(char *message) {
  77. fputs(message, stderr);
  78. fputc('\n', stderr);
  79. exit(1);
  80. }