《unix网络编程》第一卷中将客户服务器程序设计方法讲得透彻,这篇文章将其中编码的细节略去,通过伪代码的形式展现,主要介绍各种方法的思想;
示例是一个经典的TCP回射程序: 客户端发起连接请求,连接后发送一串数据;收到服务端的数据后输出到终端; 服务端收到客户端的数据后原样回写给客户端;
客户端伪代码:
sockfd = socket(AF_INET,SOCK_STREAM,0);//与服务端建立连接connect(sockfd);//连接建立后从终端读入数据并发送到服务端;//从服务端收到数据后回写到终端while(fgets(sendline,MAXLINE,fileHandler)!= NULL){ writen(sockfd,sendline,strlen(sendline)); if(readline(sockfd,recvline,MAXLINE) == 0){ cout << "recive over!"; } fputs(recvline,stdout);}
下面介绍服务端程序处理多个客户请求的开发范式;
多进程处理对于多个客户请求,服务器端采用fork的方式创建新进程来处理;
处理流程:
服务端伪代码:
listenFd = socket(AF_INET,SOCK_STREAM,0);bind(listenFd,addR);listen(listenFD);while(true){ //服务器端在这里阻塞等待新客户连接 connfd = accept(listenfd); if( fork() ==0){//子进程 close(listenfd); while(n=read(connfd,buf,MAXLINE)>0){ writen(connfd,buf); } } close(connfd);}
这种方法开发简单,但对操作系统而言,进程是一种昂贵的资源,对于每个新客户请求都使用一个进程处理,开销较大; 对于客户请求数不多的应用适用这种方法;
预先分配进程池,accept无上锁保护上一种方法中,每来一个客户都创建一个进程处理请求,完毕后再释放; 不间断的创建和结束进程浪费系统资源; 使用进程池预先分配进程,通过进程复用,减少进程重复创建带来的系统消耗和时间等待;
优点:消除新客户请求到达来创建进程的开销; 缺点:需要预先估算客户请求的多少(确定进程池的大小)
源自Berkeley内核的系统,有以下特性: 派生的所有子进程各自调用accep()监听同一个套接字,在没有用户请求时都进入睡眠; 当有新客户请求到来时,所有的客户都被唤醒;内核从中选择一个进程处理请求,剩余的进程再次转入睡眠(回到进程池);
利用这个特性可以由操作系统来控制进程的分配; 内核调度算法会把各个连接请求均匀的分散到各个进程中;
处理流程:
服务端伪代码:
listenFd = socket(AF_INET,SOCK_STREAM,0);bind(listenFd,addR);listen(listenFD);for(int i = 0;i< children;i++){ if(fork() == 0){//子进程 while(true){ //所有子进程监听同一个套接字,等待用户请求 int connfd = accept(listenfd); close(listenfd); //连接建立后处理用户请求,完毕后关闭连接 while(n=read(connfd,buf,MAXLINE)>0){ writen(connfd,buf); } close(connfd); } }}
如何从进程池中取出进程? 所有的进程都通过accept()阻塞等待,等连接请求到来后,由内核从所有等待的进程中选择一个进程处理;
处理完的进程,如何放回到池子中? 子进程处理完客户请求后,通过无限循环,再次阻塞在accpet()上等待新的连接请求;
注意: 多个进程accept()阻塞会产生“惊群问题”:尽管只有一个进程将获得连接,但是所有的进程都被唤醒;这种每次有一个连接准备好却唤醒太多进程的做法会导致性能受损;
预先分配进程池,accept上锁(文件锁、线程锁)上述不上锁的实现存在移植性的问题(只能在源自Berkeley的内核系统上)和惊群问题, 更为通用的做法是对accept上锁;即避免让多个进程阻塞在accpet调用上,而是都阻塞在获取锁的函数中;
服务端伪代码:
listenFd = socket(AF_INET,SOCK_STREAM,0);bind(listenFd,addR);listen(listenFD);for(int i = 0;i< children;i++){ if(fork() == 0){ while(true){ my_lock_wait();//获取锁 int connfd = accept(listenfd); my_lock_release();//释放锁 close(listenfd); while(n=read(connfd,buf,MAXLINE)>0){ writen(connfd,buf); } close(connfd); } }}
上锁可以使用文件上锁,线程上锁;
关于上锁的编码细节详见《网络编程》第30章;
预先分配进程池,传递描述符;与上面的每个进程各自accept接收监听请求不同,这个方法是在父进程中统一接收accpet()用户请求,在连接建立后,将连接描述符传递给子进程;
处理流程:
服务端伪代码:
listenFd = socket(AF_INET,SOCK_STREAM,0);bind(listenFd,addR);listen(listenFD);//预先建立子进程池for(int i = 0;i< children;i++){ //使用Unix域套接字创建一个字节流管道,用来传递描述符 socketpair(AF_LOCAL,SOCK_STREAM,0,sockfd); if(fork() == 0){//预先创建子进程 //子进程字节流到父进程 dup2(sockfd[1],STDERR_FILENO); close(listenfd); while(true){ //收到连接描述符 if(read_fd(STDERR_FILENO,&connfd) ==0){; continue; } while(n=read(connfd,buf,MAXLINE)>0){ //处理用户请求 writen(connfd,buf); } close(connfd); //通知父进程处理完毕,本进程可以回到进程池 write(STDERR_FILENO,"",1); } }}while(true){ //监听listen套接字描述符和所有子进程的描述符 select(maxfd+1,&rset,NULL,NULL,NULL); if(FD_ISSET(listenfd,&rset){//有客户连接请求 connfd = accept(listenfd);//接收客户连接 //从进程池中找到一个空闲的子进程 for(int i = 0 ;i < children;i++){ if(child_status[i] == 0) break; } child_status[i] = 1;//子进程从进程池中分配出去 write_fd(childfd[i],connfd);//将描述符传递到子进程中 close(connfd); } //检查子进程的描述符,有数据,表明已经子进程请求已处理完成,回收到进程池 for(int i = 0 ;i < children;i++){ if(FD_ISSET(childfd[i],&rset)){ if(read(childfd[i])>0){ child_status[i] = 0; } } }}
多线程处理为每个用户创建一个线程,这种方法比为每个用户创建一个进程要快出许多倍;
处理流程:
服务端伪代码:
listenFd = socket(AF_INET,SOCK_STREAM,0);bind(listenFd,addR);listen(listenFD);while(true){ connfd = accept(listenfd); //连接建立后,创建新线程处理具体的用户请求 pthread_create(&tid,NULL,&do_function,(void*)connfd); close(connfd);}--------------------//具体的用户请求处理函数(子线程主体)void * do_function(void * connfd){ pthread_detach(pthread_self()); while(n=read(connfd,buf,MAXLINE)>0){ writen(connfd,buf); close((int)connfd);}
预先创建线程池,每个线程各自accept处理流程:
listenFd = socket(AF_INET,SOCK_STREAM,0);bind(listenFd,addR);listen(listenFD);//预先创建线程池,将监听描述符传给每个新创建的线程for(int i = 0 ;i <threadnum;i++){ pthread_create(&tid[i],NULL,&thread_function,(void*)connfd);}--------------------//具体的用户请求处理//通过锁保证任何时刻只有一个线程阻塞在accept上等待新用户的到来;其它的线程都//在等锁;void * thread_function(void * connfd){ while(true){ pthread_mutex_lock(&mlock); // 线程上锁 connfd = accept(listenfd); pthread_mutex_unlock(&mlock);//线程解锁 while(n=read(connfd,buf,MAXLINE)>0){ writen(connfd,buf); close(connfd); }}
使用源自Berkeley的内核的Unix系统时,我们不必为调用accept而上锁, 去掉上锁的两个步骤后,我们发现没有上锁的用户时间减少(因为上锁是在用户空间中执行的线程函数完成的),而系统时间却增加很多(每一个accept到达,所有的线程都变唤醒,引发内核的惊群问题,这个是在线程内核空间中完成的); 而我们的线程都需要互斥,让内核执行派遣还不让自己通过上锁来得快;
这里没有必要使用文件上锁,因为单个进程中的多个线程,总是可以通过线程互斥锁来达到同样目的;(文件锁更慢)
预先创建线程池,主线程accept后传递描述符处理流程:
激活条件等待的方式有两种:pthread_cond_signal()激活一个等待该条件的线程,存在多个等待线程时按入队顺序激活其中一个;而pthread_cond_broadcast()则激活所有等待线程。
注:一般应用中条件变量需要和互斥锁一同使用; 在调用pthread_cond_wait()前必须由本线程加锁(pthread_mutex_lock()),而在更新条件等待队列以前,mutex保持锁定状态,并在线程挂起进入等待前解锁。在条件满足从而离开pthread_cond_wait()之前,mutex将被重新加锁,以与进入pthread_cond_wait()前的加锁动作对应。
服务端伪代码:
listenFd = socket(AF_INET,SOCK_STREAM,0);bind(listenFd,addR);listen(listenFD);for(int i = 0 ;i <threadnum;i++){ pthread_create(&tid[i],NULL,&thread_function,(void*)connfd);}while(true){ connfd = accept(listenfd); pthread_mutex_lock(&mlock); // 线程上锁 childfd[iput] = connfd;//将描述符的句柄放到数组中传给获取到锁的线程; if(++iput == MAX_THREAD_NUM) iput= 0; if(iput == iget) err_quit("thread num not enuough!"); pthread_cond_signal(&clifd_cond);//发信号,唤醒一个睡眠线程(轮询唤醒其中的一个) pthread_mutex_unlock(&mlock);//线程解锁}--------------------void * thread_function(void * connfd){ while(true){ pthread_mutex_lock(&mlock); // 线程上锁 //当无没有收到连接句柄时,睡眠在条件变量上,并释放mlock锁 //满足条件被唤醒后,重新加mlock锁 while(iget == iput) pthread_cond_wait(&clifd_cond,&mlock); connfd = childfd[iget]; if(++iget == MAX_THREAD_NUM) iget = 0; pthread_mutex_unlock(&mlock);//线程解锁 //处理用户请求 while(n=read(connfd,buf,MAXLINE)>0){ writen(connfd,buf); close(connfd); }}
测试表明这个版本的服务器要慢于每个线程各自accpet的版本,原因在于这个版本同时需要互斥锁和条件变量,而上一个版本只需要互斥锁;
线程描述符的传递和进程描述符的传递的区别? 在一个进程中打开的描述符对该进程中的所有线程都是可见的,引用计数也就是1; 所有线程访问这个描述符都只需要通过一个描述符的值(整型)访问; 而进程间的描述符传递,传递的是描述符的引用;(好比一个文件被2个进程打开,相应的这个文件的描述符引用计数增加2);
总结《unix网络编程》第一卷 套接字联网API
Posted by: 大CC | 05APR,2015 博客:blog.me115.com [订阅] 微博:新浪微博
新闻热点
疑难解答