1.单线程/进程
在TCP通信过程中,服务器端启动之后可以同时和多个客户端建立连接,并进行网络通信,但是在介绍TCP通信流程的时候,提供的服务器代码却不能完成这样的需求,先简单的看一下之前的服务器代码的处理思路,再来分析代码中的弊端:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
| #include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <string.h> #include <arpa/inet.h>
int main() { int lfd = socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in addr; addr.sin_family = AF_INET; addr.sin_port = htons(10000); addr.sin_addr.s_addr = INADDR_ANY; int ret = bind(lfd, (struct sockaddr*)&addr, sizeof(addr)); ret = listen(lfd, 128); struct sockaddr_in cliaddr; int clilen = sizeof(cliaddr); int cfd = accept(lfd, (struct sockaddr*)&cliaddr, &clilen); while(1) { char buf[1024]; memset(buf, 0, sizeof(buf)); int len = read(cfd, buf, sizeof(buf)); if(len > 0) { printf("客户端say: %s\n", buf); write(cfd, buf, len); } else if(len == 0) { printf("客户端断开了连接...\n"); break; } else { perror("read"); break; } } close(cfd); close(lfd); return 0; }
|
在上面的代码中用到了三个会引起程序阻塞的函数,分别是:
-
accept():如果服务器端没有新客户端连接,阻塞当前进程/线程,如果检测到新连接解除阻塞,建立连接
-
read():如果通信的套接字对应的读缓冲区没有数据,阻塞当前进程/线程,检测到数据解除阻塞,接收数据
-
write():如果通信的套接字写缓冲区被写满了,阻塞当前进程/线程(这种情况比较少见)
如果需要和发起新的连接请求的客户端建立连接,那么就必须在服务器端通过一个循环调用accept()函数,另外已经和服务器建立连接的客户端需要和服务器通信,发送数据时的阻塞可以忽略,当接收不到数据时程序也会被阻塞,这时候就会非常矛盾,被accept()阻塞就无法通信,被read()阻塞就无法和客户端建立新连接。因此得出一个结论,基于上述处理方式,在单线程/单进程场景下,服务器是无法处理多连接的,解决方案也有很多,常用的有三种:
- 使用多线程实现
- 使用多进程实现
- 使用IO多路转接(复用)实现
- 使用IO多路转接 + 多线程实现
2.多线程并发
多线程中的线程有两大类:主线程(父线程)和子线程,他们分别要在服务器端处理监听和通信流程。根据多进程的处理思路,就可以这样设计了:
- 主线程
- 负责监听,处理客户端的连接请求,也就是在父进程中循环调用accept()函数
- 创建子线程:建立一个新的连接,就创建一个新的子进程,让这个子进程和对应的客户端通信
- 回收子线程资源:由于回收需要调用阻塞函数,这样就会影响accept(),直接做线程分离即可。
- 子线程:负责通信,基于主线程建立新连接之后得到的文件描述符,和对应的客户端完成数据的接收和发送。
- 发送数据:send() / write()
- 发送数据:recv() / read()
在多线程版的服务器端程序中,多个线程共用同一个地址空间,有些数据是共享的,有些数据的独占的,下面来分析一些其中的一些细节:
多线程版TCP服务器示例代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122
| #include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <string.h> #include <arpa/inet.h> #include <pthread.h>
struct SockInfo { int fd; pthread_t tid; struct sockaddr_in addr; };
struct SockInfo infos[128];
void* working(void* arg) { while(1) { struct SockInfo* info = (struct SockInfo*)arg; char buf[1024]; int ret = read(info->fd, buf, sizeof(buf)); if(ret == 0) { printf("客户端已经关闭连接...\n"); info->fd = -1; break; } else if(ret == -1) { printf("接收数据失败...\n"); info->fd = -1; break; } else { write(info->fd, buf, strlen(buf)+1); } } return NULL; }
int main() { int fd = socket(AF_INET, SOCK_STREAM, 0); if(fd == -1) { perror("socket"); exit(0); }
struct sockaddr_in addr; addr.sin_family = AF_INET; addr.sin_port = htons(8989); addr.sin_addr.s_addr = INADDR_ANY; int ret = bind(fd, (struct sockaddr*)&addr, sizeof(addr)); if(ret == -1) { perror("bind"); exit(0); }
ret = listen(fd, 100); if(ret == -1) { perror("listen"); exit(0); }
int len = sizeof(struct sockaddr);
int max = sizeof(infos) / sizeof(infos[0]); for(int i=0; i<max; ++i) { bzero(&infos[i], sizeof(infos[i])); infos[i].fd = -1; infos[i].tid = -1; }
while(1) { struct SockInfo* pinfo; for(int i=0; i<max; ++i) { if(infos[i].fd == -1) { pinfo = &infos[i]; break; } if(i == max-1) { sleep(1); i--; } }
int connfd = accept(fd, (struct sockaddr*)&pinfo->addr, &len); printf("parent thread, connfd: %d\n", connfd); if(connfd == -1) { perror("accept"); exit(0); } pinfo->fd = connfd; pthread_create(&pinfo->tid, NULL, working, pinfo); pthread_detach(pinfo->tid); }
close(fd);
return 0; }
|
-
在编写多线程版并发服务器代码的时候,需要注意父子线程共用同一个地址空间中的文件描述符,因此每当在主线程中建立一个新的连接,都需要将得到文件描述符值保存起来,不能在同一变量上进行覆盖,这样做丢失了之前的文件描述符值也就不知道怎么和客户端通信了。
-
在上面示例代码中是将成功建立连接之后得到的用于通信的文件描述符值保存到了一个全局数组中,每个子线程需要和不同的客户端通信,需要的文件描述符值也就不一样,只要保证存储每个有效文件描述符值的变量对应不同的内存地址,在使用的时候就不会发生数据覆盖的现象,造成通信数据的混乱了。
3.多进程并发