《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);
}
这种方法开发简单,但对操作系统而言,进程是一种昂贵的资源,对于每个新客户请求都使用一个进程处理,开销较大;
对于客户请求数不多的应用适用这种方法;
上一种方法中,每来一个客户都创建一个进程处理请求,完毕后再释放;
不间断的创建和结束进程浪费系统资源;
使用进程池预先分配进程,通过进程复用,减少进程重复创建带来的系统消耗和时间等待;
优点:消除新客户请求到达来创建进程的开销;
缺点:需要预先估算客户请求的多少(确定进程池的大小)
源自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()阻塞会产生“惊群问题”:尽管只有一个进程将获得连接,但是所有的进程都被唤醒;这种每次有一个连接准备好却唤醒太多进程的做法会导致性能受损;
上述不上锁的实现存在移植性的问题(只能在源自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);
}
处理流程:
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到达,所有的线程都变唤醒,引发内核的惊群问题,这个是在线程内核空间中完成的);
而我们的线程都需要互斥,让内核执行派遣还不让自己通过上锁来得快;
这里没有必要使用文件上锁,因为单个进程中的多个线程,总是可以通过线程互斥锁来达到同样目的;(文件锁更慢)
处理流程:
激活条件等待的方式有两种: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 [订阅]
微博:新浪微博