客户-服务器程序设计方法

客户-服务器程序设计方法

《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的方式建立新进程来处理;网络

处理流程:
1. 主进程绑定ip端口后,使用accept()等待新客户的请求;
2. 每个新的用户请求到来,都建立一个新的子进程来处理具体的客户请求;
3. 子进程处理完用户请求,结束本进程;多线程

服务端伪代码:并发

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);
}

这种方法开发简单,但对操做系统而言,进程是一种昂贵的资源,对于每一个新客户请求都使用一个进程处理,开销较大;
对于客户请求数很少的应用适用这种方法;socket

预先分配进程池,accept无上锁保护

上一种方法中,每来一个客户都建立一个进程处理请求,完毕后再释放;
不间断的建立和结束进程浪费系统资源;
使用进程池预先分配进程,经过进程复用,减小进程重复建立带来的系统消耗和时间等待;函数

优势:消除新客户请求到达来建立进程的开销;
缺点:须要预先估算客户请求的多少(肯定进程池的大小)性能

源自Berkeley内核的系统,有如下特性:
派生的全部子进程各自调用accep()监听同一个套接字,在没有用户请求时都进入睡眠;
当有新客户请求到来时,全部的客户都被唤醒;内核从中选择一个进程处理请求,剩余的进程再次转入睡眠(回到进程池);

利用这个特性能够由操做系统来控制进程的分配;
内核调度算法会把各个链接请求均匀的分散到各个进程中;

处理流程:
1. 主进程预先分配进程池,全部子进程阻塞在accept()调用上;
2. 新用户请求到来,操做系统唤醒全部的阻塞在accpet上的进程,从其中选择一个创建链接;
3. 被选中的子进程处理用户请求,其它子进程回到睡眠;
4. 子进程处理完毕,再次阻塞在accept上;

服务端伪代码:

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()用户请求,在链接创建后,将链接描述符传递给子进程;

处理流程:
1. 主进程阻塞在accpet上等待用户请求,全部子进程不断轮询探查是否有可用的描述符;
2. 有新用户请求到来,主进程accpet创建链接后,从进程池中取出一个进程,经过字节流管道将链接描述符传递给子进程;
3. 子进程收到链接描述符,处理用户请求,处理完成后向父进程发送一个字节的内容(无实际意义),告知父进程我任务已完成;
4. 父进程收到子进程的单字节数据,将子进程放回到进程池;

服务端伪代码:

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;
            }
        }
    }
}

多线程处理

为每一个用户建立一个线程,这种方法比为每一个用户建立一个进程要快出许多倍;

处理流程:
1. 主线程阻塞在accpet上等待用请求;
2. 有新用户请求时,主线程创建链接,而后建立一个新的线程,将链接描述符传递过去;
3. 子线程处理用户请求,完毕后线程结束;

服务端伪代码:

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

处理流程:
1. 主线程预先建立线程池,第一个建立的子线程获取到锁,阻塞在accept()上,其它子线程阻塞在线程锁上;
2. 用户请求到来,第一个子线程创建链接后释放锁,而后处理用户请求;完成后进入线程池,等待获取锁;
3. 第一个子线程释放锁以后,线程池中等待的线程有一个会获取到锁,阻塞在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后传递描述符

处理流程:

  1. 主线程预先建立线程池,线程池中全部的线程都经过调用pthread_cond_wait()而处于睡眠状态(因为有锁的保证,是依次进入睡眠,而不会发生同时调用pthread_cond_wait引起竞争)
  2. 主线程阻塞在acppet调用上等待用户请求;
  3. 用户请求到来,主线程accpet创建创建,将链接句柄放入约定位置后,发送pthread_cond_signal激活一个等待该条件的线程;
  4. 线程激活后从约定位置取出链接句柄处理用户请求;完毕后再次进入睡眠(回到线程池);

激活条件等待的方式有两种: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);

总结

  • 当系统负载较轻时,每一个用户请求现场派生一个子进程为之服务的传统并发服务器模型就足够了;
  • 相比传统的每一个客户fork一次的方式,预先建立一个子进程池或线程池可以把进程控制cpu时间下降10倍以上;固然,程序会相应复杂一些,须要监视子进程个数,随着客户用户数的动态变化而增长或减小进程池;
  • 让全部子进程或线程自行调用accept一般比让父进程或主线程独自调用accpet并发描述符传递给子进程或线程要简单和快速;
  • 使用线程一般要快于使用进程;

参考资料

《unix网络编程》第一卷 套接字联网API

Posted by: 大CC
博客:blog.me115.com
微博:新浪微博

相关文章
相关标签/搜索