客戶-服務器程序設計方法

客戶-服務器程序設計方法

《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
微博:新浪微博

相關文章
相關標籤/搜索