前文列出的代码给大家展示了一个最简单的网络程序,但正如文章末尾所提的,这个最简单的网络程序最大的缺点是服务端一次只能服务一个客户端,就比如说你去吃饭,饭店只有一个服务员, 而且服务员在客户离开之前只能为一个客户服务,也就是说你只能等待你前面的客户吃好饭离开了,然后你才能进去吃饭,而在你吃饭的时候时候,你后面来的人都得等你吃完饭才能轮到你后面一个人吃饭。这种模式的缺点很明显,因为在你进去点好菜到买单前的这段时间,这个服务员都是空闲的,为什么不让服务员在这个空闲时间让其他客户进来服务员为他点菜呢?在网络编程中,这个思想是类似的,前面的代码服务端与客户端建立连接后,服务端都在为这个客户端服务,而有可能这个客户端在当时正在等待用户输入,而服务端在等待客户端数据的到达,有什么办法可以让服务端在这个等待的时间服务其他客户端呢?答案是肯定的。解决这个问题的思路有两个:
1、服务端分配多个线程/进程来处理客户端连接,一个客户端对应一个线程/进程
2、服务端的一个进程/线程对应多个连接.
仍然用到饭店吃饭来理解这个思路,思路一是餐厅招聘多个服务员,一个服务员服务一个客户,每来一个客户分配一个服务员专门为他服务。思路二是一个服务员服务多个客户,服务员可以给一个客户点好菜后给另一个客户买单,再给另外一个客户上菜。显而易见,思路一给餐厅增加了成本,来一个客户就需要一个服务员为他服务,当服务员人数既定的情况下就只能服务有限的客户,而思路二在既定服务员人数的情况下却可以进来多的服务客户。
下面分别本篇文章和下篇文章来分别介绍思路一和思路二
多进程/线程解决并发(思路一)
前文的服务端程序一次只能为一个客户端服务,那么按照思路一的方法,当一个客户端请求来时服务端分配一个线程或进程专门为这个客户端服务,带服务结束后线程或进程结束。
多线程解决并发
#include <netinet/in.h> #include <sys/socket.h> #include <errno.h> #include <stdio.h> #include <string.h> #include <unistd.h> #include <arpa/inet.h> #include <pthread.h> #include <netdb.h> void* doRead(void* arg) { long connfd = (long)arg; char recvBuf[101] = ""; int n = 0; while((n = recv(connfd,recvBuf, sizeof(recvBuf),0 )) > 0) { printf("number of receive bytes = %d.\n", n); //发送数据 send(connfd, recvBuf, n, 0); char* bufTmp = recvBuf; while(*bufTmp != ‘\r‘ && *bufTmp !=‘\n‘) bufTmp++; *bufTmp = ‘\0‘; if(strcmp(recvBuf, "quit") == 0) { break; } } close(connfd); return NULL; } int main() { struct sockaddr_in sockaddr; pthread_t thread_id; int one =1; int ret = 0; bzero(&sockaddr, sizeof(sockaddr)); sockaddr.sin_family = AF_INET; sockaddr.sin_port = htons(8080); sockaddr.sin_addr.s_addr = htonl(INADDR_ANY); long clientfd; int listenfd = socket(AF_INET, SOCK_STREAM, 0); //创建一个socket //将该套接字的绑定端口设为可重用 setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, (void*) &one,(socklen_t)sizeof(one)); if(bind(listenfd, (struct sockaddr*)&sockaddr, sizeof(sockaddr)) < 0) { perror("bind error"); return -1; } ret = listen(listenfd, 16); if(ret < 0) { perror("listen failed.\n"); return -1; } struct sockaddr_in clientAdd; socklen_t len = sizeof(clientAdd); while(1) { clientfd = accept(listenfd, (struct sockaddr *)&clientAdd, &len); if(clientfd < 0) { perror("accept this time"); continue; } if(clientfd > 0) { //由于同一个进程内的所有线程共享内存和变量,因此在传递参数时需作特殊处理,值传递 pthread_create(&thread_id, NULL, doRead, (void *)clientfd); printf("thread %d created.\n",thread_id ); pthread_detach(thread_id); } } close(listenfd); return 0; }
程序每当客户端发起一个连接时,服务端接受客户端连接accpet返回成功后,都创建一个线程,并将返回的客户端的连接描述符fd传递给线程处理函数,在线程的处理函数doRead处理客户端数据的收发。需要注意的是pthread_create传递的第四个参数传递的是将long类型的数据强转成void *类型的数据,为什么是long类型不是int型呢?这个在没个机器上可能都不一样,在笔者的机器上,int是4个字节,而void*类型是8个字节,所以必须将socket描述符定义成8个字节long类型,否则int型装成long类型编译器报错,此外需要注意的是这里传递的不是clientfd的地址,如果传递了clientfd的地址,那么每当来一个连接的时候,变了clientfd都会被改写,而线程是共享进程的内存空间的,也就是说吐过传递了clienfd的地址所有线程都会使用同一个clienfd,到时其他客户端无法和服务端通信。
多线程解决并发的优缺点:
优点:使用多线程解决并发的优点是编码比较简单,代码量少,同时响应比较快。
缺点:使用多线程的缺点比较多,多线程解决并发受限于系统资源,创建一个线程默认要创建1M大小的栈空间,而一个进程的地址空间是2G,因此一个进程最多创建2000个线程,此外线程创建,销毁,切换都需要消耗系统资源,因此线程数量到一定程度后哪怕增加系统资源也无法增加线程数量,对于大的并发量无法支持。如果程序间需要涉及到数据的同步互斥,那么整个逻辑会比较麻烦,线程间的资源同步互斥比较难控制,除了问题也比较难调试。一个线程崩溃了可能引起整个进程的崩溃,进而影响服务器性能。
多进程解决并发
思路一的另一方法师用多进程来解决并发,每来一个客户端请求,创建一个子线程,子线程服务客户端请求,父线程继续监听客户端连接。
#include <netinet/in.h> #include <sys/socket.h> #include <unistd.h> #include <stdlib.h> #include <stdio.h> void doread(int fd) { int n = 0; char buf[101] = {0}; while((n = recv(fd, buf, sizeof(buf), 0))> 0) { printf("msg recv is %s\n", buf); send(fd,buf, sizeof(buf), 0); char* bufTmp = buf; while(*bufTmp != ‘\r‘ && *bufTmp !=‘\n‘) bufTmp++; *bufTmp = ‘\0‘; if(strcmp(buf, "quit") == 0) { break; } } } int main() { struct sockaddr_in SerAddr; SerAddr.sin_addr.s_addr = htonl(INADDR_ANY); SerAddr.sin_port = htons(12345); SerAddr.sin_family = AF_INET; int listenfd = socket(AF_INET,SOCK_STREAM,0); if(bind(listenfd, (struct sockaddr*)&SerAddr, sizeof(SerAddr)) < 0) { perror("bind failed"); return -1; } if(listen(listenfd, 64) < 0) { perror("listen failed\n"); return -1; } while(1) { int connfd; struct sockaddr_in clientAddr; socklen_t len = sizeof(clientAddr); connfd = accept(listenfd, (struct sockaddr*)&clientAddr, &len); if(connfd < 0) continue; if(fork() == 0) { close(listenfd); doread(connfd); close(connfd); } close(connfd); }
close(listenfd); }
从代码中可以看到,父进程创建一个监听socket,并监听来自客户端的请求,每当来一个客户端请求时,创建一个子进程,子进程doread方法处理与客户端的通信。需要注意的是,在子进程创建的开始的时候需要调用close(listenfd),结束时调用close(connfd),而在父进程中调用close(connfd),这是由于父进程和子进程共享资源,而每个文件描述符都有一个引用计数,当fork成功时,listenfd和connfd的引用计数都会变为2,而close函数将判断当前描述符的引用计数,若引用计数为1时才关闭描述符,否则仅将引用计数减1.因此子进程中需要先将listenfd的引用计数减1,,父进程中将connfd的引用计数减1.
多进程解决并发的优缺点
多进程的优点:每个进程相互独立,一个进程的崩溃不会引起另外进程崩溃。通过增加CPU就可以提高性能,且没有同步互斥的复杂控制逻辑。
多进程缺点:进程的创建销毁消耗系统资源,此外如果有跨进程的数据通信就比较复杂,使用只有小数据量的进程间通信的场景,进程的多少受限于系统资源。
总结
虽然多线程/进程可以在一定程度上解决并发,但由于受限于系统资源,解决并发的能力有限,多线程、进程的创建销毁和切换都需要消耗系统资源,此外类似的线程同步互斥,进程间通信就逻辑比较复杂,出问题很难调试。因此在网络大规模应用的今天,通过多线程/进程来解决并发并不合适。因此有人用线程池计数来解决上述的限制,如传统的Apache服务器貌似就是用这种线程池的池化技术,也有将多线程和多进程结合起来的方法,虽然一定程度上提高了并发,但都有一定的局限性。
说明:本系列的例子中有资源的代码
while(*bufTmp != ‘\r‘ && *bufTmp !=‘\n‘) bufTmp++; *bufTmp = ‘\0‘;
那是因为笔者的编程环境是虚拟机+ssh,通过SSH发送的数据发现字符串末尾都带了"\r\n"回车换行字符,因此需要与字符串quit进行比较的时候需要作转换,这部分代码仅用于去掉字符串末尾的‘\r‘ ‘\n\’字符。