Linux网络编程之socket(三):信号量、IO多路复用模型的产生原因及实现分析
一、信号量
1. 为什么需要信号量?
- 回顾线程池BIO模型的实现,我们预先开的n个线程都在不停的循环排队检查任务队列的队首(不管此时有没有任务),这就导致了非常大的CPU消耗(占用了太多的CPU周期)。所以我们需要一种方法来
控制服务端线程在空闲时段的对CPU占用,同时又可以使其面对高并发时又能够及时的唤醒所有的线程来快速响应客户端
。信号量可以很好的解决这个问题,所以本节我们来学习一下如何使用信号量来改进线程池BIO模型。
2. 信号量的使用方法及改进前后服务端的CPU占用对比
2.1 使用top动态监听用户名下的进程运行状态
- 命令如下:
- top -u username:查看username下的进程运行状态
- top -p PID:查看指定ID的进程
- 我们通过top -u username来动态查看当前用户下的进程,可以发现,线程池BIO版本的webserver在未有任何连接时,CPU占用率就高的惊人。
其中:PID表示线程ID、USER表示该进程所属的用户、VIRT表示该进程使用的虚拟内存大小(KB)、RES表示该进程使用的物理内存大小(KB)、%CPU表示占用CPU的百分比、%MEM表示占用内存的百分比、TIME表示该进程共占用CPU的时间。所以我们可以从top的监测结果中看出,该服务器程序对CPU的占有率是十分恐怖的,如果此时我们给服务器分配的CPU资源不够多,那服务器几乎就不能再做其他任何事情了,所以这样的程序在实际中是无法使用的。
2.2 如何降低线程在空闲时段对CPU的占用?
- 解决方法1:通过sleep(),当子线程尝试从任务队列中取任务却没有取到时,我们就sleep()当前线程
- 这样的修改后对客户端的响应会比之前慢,因为当任务进入任务队列后,它需要等待一个线程被唤醒从而来执行这个任务,如下图所示(依旧出自YouTube这位老师):
如图所示,因为sleep(time)是程序中写好的固定值,所以当有连接到达时,当前并不一定有线程醒着,所以客户端需要等待(0,time)的时间来使线程sleep()结束重新检查队首。这样的程序就会导致服务端对客户端的响应时间会增加(如果你点开一个网页需要过很久,可能你也是不愿意的),所以通过长时间的sleep()来降低子线程对CPU的占用在应用中是不可行的。
- 那如果我们将睡眠时间修改成很短呢?如果睡眠时间很长,客户端响应就会很长,但是服务端占用的CPU周期就少(资源消耗少),如果睡眠时间很短,客户端的响应就很快,但就会占用太多的CPU周期,所以这就需要在CPU占用和客户端的响应之间做一个抉择,即你想给这个服务端分配多少CPU资源和你想给客户端多快的响应。
- 综上,使用sleep()并不是一个很好的解决方法。
- 这样的修改后对客户端的响应会比之前慢,因为当任务进入任务队列后,它需要等待一个线程被唤醒从而来执行这个任务,如下图所示(依旧出自YouTube这位老师):
- 解决方法2:
条件变量(condition variable:let's threads wait until something happens and it can do useful work)
,条件变量可以使线程在某些条件下挂起,并在获得信号量后重新运行。 - 信号量使用方法:
- pthread_cond_wait(pthread_cond_t* cond,pthread_mutex_t* mutex):使线程阻塞等待(挂起),即线程将会不做任何事情,一直在那等待,直到另一个线程调用signal()来唤醒它。
pthread_cond_wait()必须和互斥锁一起使用,因为线程一旦进入wait状态就需要立刻释放锁,使得其他线程可以访问资源
。如果不释放则程序会进入死锁状态。 - pthread_cond_signal(pthread_cond_t* cond):发送一个信号给正在阻塞等待的线程,唤醒该进程使其继续执行。该函数只会给一个线程发送信号,如果有多个线程正在等待则按照优先级>等待时间的顺序来决定哪个线程获取这个信号。
- pthread_cond_wait(pthread_cond_t* cond,pthread_mutex_t* mutex):使线程阻塞等待(挂起),即线程将会不做任何事情,一直在那等待,直到另一个线程调用signal()来唤醒它。
- 将信号量加入到线程池BIO模型的服务端程序中:
- 我们只需要在程序中修改这3处地方即可:
首先定义一个条件变量并初始化
修改子线程的入口函数,使子线程在没有取到任务后进入阻塞等待状态(挂起,即不再占用cpu,等待得到信号量后再次运行)。
这里158行为什么要再读一次呢?因为该函数是让一个线程阻塞在这里,所以当这个线程被唤醒后(它就需要继续工作了),于是就让他再读一下队首,就保证了导致它被唤醒的这个工作是由它来做的。这样做的关键就是避免了它再重走一遍循环,任务又被抢了又重新挂起,就避免这种无用的竞争发生
。
- 我们只需要在程序中修改这3处地方即可:
- 加了信号量之后,我们再查一下cpu,可以发现从cpu占用排行上都找不到server了,这是因为线程都被挂起了,只有队列中进一个任务,才会唤醒一个,而挂起的线程不占用任何cpu,所以信号量可以很好的解决线程池BIO存在的问题(闲时对CPU的占用过大)。
3 基于信号量的多线程BIO模型的优缺点分析:
- 【优点】现在这个webserver.c已经是一个可以工作的服务端了,它
可以处理多个客户端的并发连接
(单线程BIO的缺陷)、可以降低多线程并发下的线程频繁创建、销毁带来的巨大消耗以及海量客户端连接带来的服务端性能退化甚至崩溃问题
(多线程BIO的缺陷)、可以降低子线程在闲时对CPU的高占用问题
(线程池BIO的缺陷)。即该程序可以在不浪费CPU资源的情况下,可以很好的处理多客户端的并发请求。 - 【缺点】但是它在满足了服务端性能需求时也存在着自己的安全性问题(或者说不能处理大量的长连接)。这样的服务器容易受到特定类型的服务攻击(大量的长连接),如果客户端需要很长时间才能发生它的消息,那线程池中的一个线程就必须一直等待它。那此时如果有大量这样的连接进来,线程池中的线程就被占用完了,我们的服务端就无法再处理其他新任务(即服务端没用了)。我们可以通过
事件驱动(Event-driven)
的程序和异步IO模型(Asyncronous I/O)
来解决这个问题。这就是我们接下来要学习的IO多路复用模型
。
二、IO多路复用之select()
1、 IO多路复用、select()的相关概念介绍
-
IO多路复用:
IO多路复用使程序能够同时监听多个文件描述符
。Linux下实现IO多路复用的系统调用有select()、poll()和epoll()。这些系统调用本身(IO多路复用)是阻塞的!
但是它能够让程序在单线程的情况下,并发的操作多个文件
,为我们的程序带来了一定的并发性(本质上,一个线程通过IO多路复用来处理多个文件描述符是一个串行操作)- select的功能是监视一组文件描述符的集合,然后在被调用后立刻返回其中就绪的文件描述符(有点像异步)。即线程调用select()去监视一个集合,然后select()返回后,这个集合里就只剩就绪的文件描述符了。(白话:假如我有10个连接,则我让select去监视这个10个连接,然后有连接读就绪时,通知我去读,但是我并不知道是哪个好了,所以我需要去集合中轮询一遍,找到就绪的这个fd,然后对其进行处理)。
- 解释:这地方的返回的集合会改变有点难理解,我个人的理解是,因为集合是一个数组,通过每个位的0/1来表示当前这个fd有没有好。所以集合大小是不变的,所以虽然只好了一个文件,我们却需要轮询整个"数组"才能知道是哪个fd好了。
- select的功能是监视一组文件描述符的集合,然后在被调用后立刻返回其中就绪的文件描述符(有点像异步)。即线程调用select()去监视一个集合,然后select()返回后,这个集合里就只剩就绪的文件描述符了。(白话:假如我有10个连接,则我让select去监视这个10个连接,然后有连接读就绪时,通知我去读,但是我并不知道是哪个好了,所以我需要去集合中轮询一遍,找到就绪的这个fd,然后对其进行处理)。
-
大多数标准IO函数是执行输出/输出的,它们大多数都是使用阻塞的方法。这意味着我们调用它们读取文件中的数据时,当前线程会进入阻塞状态,直到操作完成(有时也将阻塞调用称为同步调用)。所以
如果不想让线程在等待时不进入阻塞状态,则可以使用非阻塞函数/异步调用的方法
。- 非阻塞函数/异步调用:线程对一个函数发起调用后,调用会立刻return(此时返回的不是事情处理的结果,而是答应去工作的状态应答),然后线程就能接着干其他事去了,但此时任务才刚开始由另一个独立线程开始执行,至于它的请求什么时候被处理好(它读完缓冲区里的数据了),这交给事件/信号/回调函数/类似的终端来告诉这个线程,它的请求已经被处理好了,它可以回去接着处理这个请求结果了。
2、select()的函数介绍及如何实现一个select()模式下的线程(即让一个线程通过select来并发处理多个客户端)
-
select()函数介绍:
- 头文件:include<sys/select.h>
- int select(int nfds,fd_set* readfds,fd_set* writefds,fd_set* exceptfds,struct timeval* timeout):函数成功时,返回就绪的文件描述符的总数,如果在timeval内没有任何文件描述符就绪则返回0。函数失败时返回-1并设置errno。
- nfds:指定被监听的文件描述符的数量上限(即这个set有多大),通常被设置为被select所监听的文件描述符的最大值+1,因为文件描述符是从0开始的。
- fd_set:一个整型数组,每一位代表一个fd,数组的容量由nfds决定。(所以每次select监测到有文件就绪后,线程都要轮询一遍该数组,以确定是哪个fd好了)。以下为对fd_set进行相关操作的函数:
- FD_ZERO(fd_set* fdset):清除set数组中所有bit位(一般用于初始化)
- FD_SET(int fd,fd_set* fdset):用于设置set数组中,fd对应的bit位(轮询到就绪的fd,就通过这个将其加入set,交给select来监控)
- FD_CLR(int fd,fd_set* fdset):用于清除set数组中,fd对应的bit位(处理完成某个fd,通过这个将其移除select监控的set)
- int FD_ISSET(int fd,fd_set* fdset):用于判断set中的fd个bit位是否为真(如果为真,则说明就绪的是该fd)
- readfds:指向可读的文件描述符集合,可以为NULL。
- writefds:指向可写的文件描述符集合,可以为NULL。
- exceptfds:指向异常事件对于的文件描述符集合,可以为NULL。
- timeout:表示select()会阻塞的时间(select本身是个阻塞函数),如果传入0则立刻返回,如果传入NULL则必须等到有文件描述符就绪了才会返回。
-
单线程使用select()来实现IO多路复用(一个读写IO,并发处理多个客户端)
- 首先我们看一下一个封装过的单线程的服务端程序(对前几篇文章中比较乱的程序封装了一下),然后我们会修改这个程序,通过select()系统调用来使该线程能够IO多路复用,并发处理多个客户端。
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <sys/socket.h> #include <arpa/inet.h> #include <stdbool.h> #include <limits.h> #define server_port 8989 #define buf_size 4096 #define SOCKETERROR (-1) #define SERVER_BACKLOG 100 typedef struct sockaddr_in SA_IN; typedef struct sockaddr SA; //prototypes void* handle_connection(int); int check(int exp,const char* msg); int accept_new_connection(int connectfd); int setup_server(short port,int backlog);//创建监听描述符 int main(int argc,char** argv){ //监听,等待连接 int listenfd=setup_server(server_port,SERVER_BACKLOG); //接受,并处理链接 while(true){ printf("Waiting for connections...\n"); //wait for,and eventually accept an incoming connection int conntecfd=accept_new_connection(listenfd); handle_connection(conntecfd); } return 0; } int setup_server(short port,int backlog){ int listenfd,connectfd,addr_size; SA_IN server_addr; check((listenfd=socket(AF_INET,SOCK_STREAM,0)),"Fail to create socket!\n"); //初始化服务端要监听(运行)的地址 server_addr.sin_family=AF_INET; server_addr.sin_addr.s_addr=INADDR_ANY;//即0.0.0.0:代表本机所有ip server_addr.sin_port=htons(server_port); check(bind(listenfd,(SA*)&server_addr,sizeof(server_addr)),"Bind Failed!\n"); check(listen(listenfd,backlog),"Listen Failed!\n"); return listenfd; } int accept_new_connection(int listenfd){ int addr_size=sizeof(SA_IN); int connectfd; SA_IN client_addr; check(connectfd=accept(listenfd,(SA*)&client_addr,(socklen_t*)&addr_size),"Accept failed\n"); return connectfd; } //错误检查 如果有错误直接结束当前程序 int check(int exp,const char *msg){ if(exp==SOCKETERROR ){ perror(msg); exit(1); } return exp; } void* handle_connection(int connectfd){ char buffer[buf_size]; size_t bytes_read; int msg_size=0; char actualpath[PATH_MAX+1]; //read the client's message which is the name of the file to read while ((bytes_read=read(connectfd,buffer+msg_size,sizeof(buffer)-msg_size-1))) { msg_size+=bytes_read; if(msg_size>buf_size-1||buffer[msg_size-1]=='\n') break; } check(bytes_read,"recv error\n"); buffer[msg_size-1]=0;//null terminate the message and remove the \n printf("Request:%s\n",buffer); fflush(stdout); //validity check if(realpath(buffer,actualpath)==NULL){ printf("Error(bad path):%s\n",buffer); close(connectfd); return NULL; } //read file and send its context to client FILE* fp=fopen(actualpath,"r"); if(fp==NULL){ printf("Error(open):%s\n",buffer); close(connectfd); return NULL; } //read file contents and send them to client //note this is a fine example program,but rather insecure //a real program would probably limit the client to certain files while((bytes_read=fread(buffer,1,buf_size,fp))>0){ //priintf("sending %zu bytes\n",bytes_read); write(connectfd,buffer,bytes_read); } close(connectfd); fclose(fp); printf("closing connection\n"); return NULL; }
- 接着我们看添加了select()之后的程序:
经过这些函数的封装,使得我们修改后的程序,只需要修改main()逻辑,所以我们这里通过分析main()函数的逻辑来分析一下select()是如何具体操作,使该线程能够并发处理多个客户端的:#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <sys/socket.h> #include <arpa/inet.h> #include <stdbool.h> #include <limits.h> #define server_port 8989 #define buf_size 4096 #define SOCKETERROR (-1) #define SERVER_BACKLOG 100 typedef struct sockaddr_in SA_IN; typedef struct sockaddr SA; //prototypes void* handle_connection(int); int check(int exp,const char* msg); int accept_new_connection(int listenfd); int setup_server(short port,int backlog);//创建监听描述符 int main(int argc,char** argv){ //监听,等待连接 int listenfd=setup_server(server_port,SERVER_BACKLOG); int max_socket_so_far=0; fd_set current_sockets,ready_sockets;//1.创建两个文件描述符集合(实际上是一个bit字段,书P147),我们不需要直接对其进行位操作,库提供了一些函数 //initialize my current set:先清空集合(初始化),然后把我们要select监视的文件放进去 FD_ZERO(¤t_sockets); FD_SET(listenfd,¤t_sockets);//让select监视listenfd所指向的文件 //max_socket_so_far printf("FD_SETSIZE=%d\n",FD_SETSIZE); //接受,并处理链接 while(true){ //因为select具有破坏性,所以我们实际上都是在它的副本上进行操作 ready_sockets=current_sockets; if(select(FD_SETSIZE,&ready_sockets,NULL,NULL,NULL)<0){ perror("select error\n"); exit(EXIT_FAILURE); } //当select返回,我们知道一个文件描述就绪了,但是是哪个呢?这是有策略的,因为这会改变我们的FD——SET //传入ready_sockets告诉select要关注哪些文件描述符,但是他返回后的FD_SET里就只准备就绪的描述副,所以集合被改变了,所以我们需要每轮循环都初始化一下副本,然后把副本传给select! //那我们如何知道返回集合中的哪个好了呢?我们需要去检查 for(int i=0;i<FD_SETSIZE;++i){ if(FD_ISSET(i,&ready_sockets)){ if(i==listenfd){ //this is a new connection int connectfd=accept_new_connection(listenfd); FD_SET(connectfd,¤t_sockets); } else{ //do whatever we do with connections handle_connection(i); FD_CLR(i,¤t_sockets); } } } } //return 0; return EXIT_SUCCESS;//因为这个程序会永远运行下去,除非我们Ctrl+C }
- 首先建立一个集合ready_socket(即一个fd_set类型的数组),该数组的容量FD_SETSIZE决定了select最多可以监视多少fd的读/写就绪。然后再建立一个ready_socket的副本(看下面的代码就知道为什么了,这里先说结论,select是会改变实参set的,所以我们不能将监听集合本身传入,而是要传入一个副本)。
- 然后通过FD_ZERO将监听集合初始化,并通过FD_SET传入listenfd让select监听--------->
即线程自己不再阻塞在accept()处来等待客户端的连接了,而是select在监测到listenfd就绪之后通知我,然后我再去accept()它,这样保证了accept()不会阻塞,而是立刻得到返回值
。(accept()本身仍是阻塞函数,是我们通过select来避免它发生阻塞/等待,因为只有数据ok了,我们才通知它去accept) - 然后我们就开始无限循环,select阻塞等待(因为time_wait设置为NULL,所以select会一直等待到有文件就绪才会return),当select()return后,主线程就知道了,有fd好了,但是select监视的是FD_SET这个大集合,告诉我们的内容也是这个大集合里有fd好了,所以我们现在不知道哪个fd好了,所以我们要for轮询这个set,来找到就绪的那个fd,然后找到后分类讨论:
- 如果就绪的fd是listenfd:说明有新的连接进来了,所以调用accept_new_connection()来建立连接,并把该新建立的connectfd放入set。
- 如果就绪的fd是某个connectfd:主线程执行handle_connection()来干活.这里和accept()一样,
通过select(),可以保证在connectfd读就绪的时候,我们才会调用read时,所以线程不会阻塞
,啪的一下就读完了,也就提升了效率。处理完之后移除该连接。
- 首先我们看一下一个封装过的单线程的服务端程序(对前几篇文章中比较乱的程序封装了一下),然后我们会修改这个程序,通过select()系统调用来使该线程能够IO多路复用,并发处理多个客户端。
-
select()实现IO多路复用的优缺点分析:
- select优点:通过select来避免无效的等待(即通过select这个代理,我们去处理fd时,保证了里面必有数据,我们啪的一下就读完结束了,不用等待!)-----》所以单线程能够更好的利用它的时间(不再会因为一个客户端的低效阻塞而影响别的客户端,因为要么没fd要处理程序阻塞在select处,一旦有fd要处理,read则可以立刻获得完整数据,然后就下一位)
- select的缺点:
- 每次都要把完整的set遍历一遍,太多无用的消耗(如果此时我只监视两个套接字,但是我的set是1024的长度,浪费太多了)----->解决方法:动态修改长度,但是动态修改长度可能会造成连接太多超过集合大小,所以也不是好方法。
- 每次进入循环都要拷贝一遍以及select本身每次从内核态到用户态的拷贝,拷贝这种操作消耗是非常大的。
- set的长度在一开始就由FD_SETSIZE指定,连接数有限,扩展性弱。
- NOTE:由于是单线程,所以如果在开始执行就绪的任务后不做任何其他操作,该线程是一次处理一整个连接。即一旦select将连接交给线程了,那线程只有处理完它,才会结束。所以此时如果客户端只发一点点数据就停止,那我还是在浪费时间等待。------->所以本代码中,每次select返回的connectfd只会被执行一次read就被close,而不是进行一个多轮次的读写。这样就保证了线程不会在read()处阻塞,并一次性读完对方的一条完整请求。
Reference
- 仍然是YouTube上这位老师的课程,本文的代码例程也出自这里,强烈建议观看原视频www.youtube.com/c/JacobSorb…