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

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

《unix网络编程》第一卷中将传统的客户服务器程序设计方法讲得透彻,这篇文章将其中编码的细节略去,通过伪代码的形式展现,主要介绍各种方法的思想;(后面再续上一篇现代服务器的主要设计方法,基本是围绕这Reactor做文章)

示例是一个经典的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);
}

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

预先分配进程池,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 | 05APR,2015
博客:blog.me115.com [订阅]
微博:新浪微博

posted @ 2015-04-08 07:52  大CC  阅读(2603)  评论(6编辑  收藏  举报
木书架 大CC的博客