Linux網絡編程 - 阻塞I/O 的進程模型 和 線程模型

阻塞I/O的進程模型

fork 函數:

pid_t fork(void)
返回:在子進程中爲0,在父進程中爲子進程ID,若出錯則爲-1

程序調用 fork 一次,卻在父、子進程裏各返回一次。在調用該函數的進程(即爲父進程)中返回的是新派生的進程 ID 號,在子進程中返回的值爲 0。fork 函數實現的時候,實際上會把當前父進程的所有相關值都克隆一份,包括地址空間、打開的文件描述符、程序計數器等,就連執行代碼也會拷貝一份,新派生的進程的表現行爲和父進程近乎一樣,就好像是派生進程調用過 fork 函數一樣。

if(fork() == 0){
  do_child_process(); //子進程執行代碼
}else{
  do_parent_process();  //父進程執行代碼
}

當一個子進程退出時,系統內核還保留了該進程的若干信息,比如退出狀態。這樣的進程如果不回收,就會變成殭屍進程。在 Linux 下,這樣的“殭屍”進程會被掛到進程號爲 1 的 init 進程上。所以,由父進程派生出來的子進程,也必須由父進程負責回收否則子進程就會變成殭屍進程。殭屍進程會佔用不必要的內存空間,如果量多到了一定數量級,就會耗盡我們的系統資源。

有兩種方式可以在子進程退出後回收資源,分別是調用 wait 和 waitpid 函數。

pid_t wait(int *statloc);
pid_t waitpid(pid_t pid, int *statloc, int options);

函數 wait 和 waitpid 都可以返回兩個值,一個是函數返回值,表示已終止子進程的進程 ID 號,另一個則是通過 statloc 指針返回子進程終止的實際狀態。這個狀態可能的值爲正常終止、被信號殺死、作業控制停止等。如果沒有已終止的子進程,而是有一個或多個子進程在正常運行,那麼 wait 將阻塞,直到第一個子進程終止。

waitpid 可以認爲是 wait 函數的升級版,它的參數更多,提供的控制權也更多。pid 參數允許我們指定任意想等待終止的進程 ID,值 -1 表示等待第一個終止的子進程。options 參數給了我們更多的控制選項。

處理子進程退出的方式一般是註冊一個信號處理函數,捕捉信號 SIGCHILD 信號,然後再在信號處理函數裏調用 waitpid 函數來完成子進程資源的回收。SIGCHLD 是子進程退出或者中斷時由內核向父進程發出的信號,默認這個信號是忽略的。所以,如果想在子進程退出時能回收它,需要像下面一樣,註冊一個 SIGCHILD 函數。

signal(SIGCHLD, sigchld_handler);  

 一張圖展示這種進程模型:

                                              

服務端程序舉個例子:

#define MAX_LINE 4096

char rot13_char(char c) {
    if ((c >= 'a' && c <= 'm') || (c >= 'A' && c <= 'M'))
        return c + 13;
    else if ((c >= 'n' && c <= 'z') || (c >= 'N' && c <= 'Z'))
        return c - 13;
    else
        return c;
}

void child_run(int fd) {
    char outbuf[MAX_LINE + 1];
    size_t outbuf_used = 0;
    size_t result;

    while (1) {
        char ch;
        result = recv(fd, &ch, 1, 0);
        if (result == 0) {
            break;
        } else if (result == -1) {
            printf("read");
            break;
        }

        if (outbuf_used < sizeof(outbuf)) {
            outbuf[outbuf_used++] = rot13_char(ch);
        }

        if (ch == '\n') {
            send(fd, outbuf, outbuf_used, 0);
            outbuf_used = 0;
            continue;
        }
    }
}

void sigchld_handler(int sig) {
    /*WNOHANG 用來告訴內核,即使還有未終止的子進程也不要阻塞在 waitpid 上
        因爲 wait 函數在有未終止子進程的情況下,沒有辦法不阻塞*/
    /*一個waitpid不足夠阻止殭屍進程,如果n個子進程同時停止,那麼會同時發出n個SIGCHILD信號給父進程,但是信號處理函數執行一次,因爲信號一般是不排隊的,多個SIGCHILD只會發送一次給父進程。所以需要用循環waitpid處理,獲取所有終止子進程狀態。*/
    while (waitpid(-1, 0, WNOHANG) > 0);
    return;
}

int main() 
{
    struct servaddr_in  serv_addr;
    int listener_fd = socket(PF_INET, SOCK_STREAM, 0);
 
    bzero(&serv_addr, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_port = htons(7878);
    serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    int on = 1;
    setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
    bind(listenfd, (struct sockaddr*) &serv_addr, sizeof(serv_addr));
    listen(listenfd, SOMAXCONN);

    signal(SIGCHLD, sigchld_handler);
    while (1) {
        struct sockaddr_storage ss;
        socklen_t slen = sizeof(ss);
        int fd = accept(listener_fd, (struct sockaddr *) &ss, &slen);
        if (fd < 0) {
            printf("accept failed\n");
            exit(0);
        }

        if (fork() == 0) {
            //子進程不需要關心監聽套接字
            close(listener_fd);
            child_run(fd);
            exit(0);
        } else {
            //父進程不需要關心連接套接字
            close(fd);
        }
    }

    return 0;
}

說說這裏的close函數,從父進程派生出的子進程,同時也會複製一份描述字,就是說,連接套接字和監聽套接字的引用計數都會被加 1,而調用 close 函數則會對引用計數進行減 1 操作,這樣在套接字引用計數到 0 時,纔可以將套接字資源回收。所以,這裏的 close 函數非常重要,缺少了它們,就會引起服務器端資源的泄露

阻塞I/O的線程模型

在同一個進程下,線程上下文切換的開銷要比進程小得多。怎麼理解線程上下文呢?我們的代碼被 CPU 執行的時候,是需要一些數據支撐的,比如程序計數器告訴 CPU 代碼執行到哪裏了,寄存器裏存了當前計算的一些中間值,內存裏放置了一些當前用到的變量等,從一個計算場景,切換到另外一個計算場景,程序計數器、寄存器等這些值重新載入新場景的值,就是線程的上下文切換。

POSIX 線程是現代 UNIX 系統提供的處理線程的標準接口。POSIX 定義的線程函數大約有 60 多個,這些函數可以幫助我們創建線程、回收線程。

主要線程函數

  • 線程創建
int pthread_create(pthread_t *tid, const pthread_attr_t *attr,
           void *(*func)(void *), void *arg);

返回:若成功則爲0,若出錯則爲正的Exxx值

在新線程的入口函數內,可以執行 pthread_self 函數返回線程 tid。

  • 線程終止
void pthread_exit(void *status)

當調用這個函數之後,父線程會等待其他所有的子線程終止,之後父線程自己終止。當然,如果一個子線程入口函數直接退出了,那麼子線程也就自然終止了。所以,絕大多數的子線程執行體都是一個無限循環。

也可以通過調用 pthread_cancel 來主動終止一個子線程,和 pthread_exit 不同的是,它可以指定某個子線程終止。

int pthread_cancel(pthread_t tid)
  • 回收已終止線程的資源
int pthread_join(pthread_t tid, void ** thread_return)

當調用 pthread_join 時,主線程會阻塞,直到對應 tid 的子線程自然終止。和 pthread_cancel 不同的是,它不會強迫子線程終止。

  • 線程分離

一個線程的重要屬性是可結合的,或者是分離的。一個可結合的線程是能夠被其他線程殺死和回收資源的;而一個分離的線程不能被其他線程殺死或回收資源。一般來說,默認的屬性是可結合的。

int pthread_detach(pthread_t tid)

在高併發的例子裏,每個連接都由一個線程單獨處理,在這種情況下,服務器程序並不需要對每個子線程進行終止,這樣的話,每個子線程可以在入口函數開始的地方,把自己設置爲分離的,這樣就能在它終止後自動回收相關的線程資源了,就不需要調用 pthread_join 函數了。

每個連接一個線程處理

服務端程序舉例:

extern void loop_echo(int);

void thread_run() {
    pthread_detach(pthread_self());
    int fd = (int) arg;
    loop_echo(fd);
}

int main(int c, char **v) {
    struct servaddr_in  serv_addr;
    int listener_fd = socket(PF_INET, SOCK_STREAM, 0);
 
    bzero(&serv_addr, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_port = htons(7878);
    serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    int on = 1;
    setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
    bind(listenfd, (struct sockaddr*) &serv_addr, sizeof(serv_addr));
    listen(listenfd, SOMAXCONN);

    pthread_t tid;
    
    while (1) {
        struct sockaddr_storage ss;
        socklen_t slen = sizeof(ss);
        int fd = accept(listener_fd, (struct sockaddr *) &ss, &slen);
        if (fd < 0) {
            printf("accept failed\n");
        } else {
            //通過強制把描述字轉換爲 void * 指針的方式完成傳值,但是這個指針裏存放的並不是一個地址,而是連接描述符的數值。
            pthread_create(&tid, NULL, &thread_run, (void *) fd);
        }
    }

    return 0;
}

loop_echo 的程序如下,在接收客戶端的數據之後,再編碼回送出去。

char rot13_char(char c) {
    if ((c >= 'a' && c <= 'm') || (c >= 'A' && c <= 'M'))
        return c + 13;
    else if ((c >= 'n' && c <= 'z') || (c >= 'N' && c <= 'Z'))
        return c - 13;
    else
        return c;
}

void loop_echo(int fd) {
    char outbuf[MAX_LINE + 1];
    size_t outbuf_used = 0;
    ssize_t result;
    while (1) {
        char ch;
        result = recv(fd, &ch, 1, 0);

        //斷開連接或者出錯
        if (result == 0) {
            break;
        } else if (result == -1) {
            error(1, errno, "read error");
            break;
        }

        if (outbuf_used < sizeof(outbuf)) {
            outbuf[outbuf_used++] = rot13_char(ch);
        }

        if (ch == '\n') {
            send(fd, outbuf, outbuf_used, 0);
            outbuf_used = 0;
            continue;
        }
    }
}

構建線程池處理多個連接

上面的服務器端程序雖然可以正常工作,不過它有一個缺點,那就是如果併發連接過多,就會引起線程的頻繁創建和銷燬。雖然線程切換的上下文開銷不大,但是線程創建和銷燬的開銷卻是不小的。我們可以使用預創建線程池的方式來進行優化。在服務器端啓動時,可以先按照固定大小預創建出多個線程,當有新連接建立時,往連接字隊列裏放置這個新連接描述字,線程池裏的線程負責從連接字隊列裏取出連接描述字進行處理。

                                       

這個程序的關鍵是連接字隊列的設計,因爲這裏既有往這個隊列裏放置描述符的操作,也有從這個隊列裏取出描述符的操作。需要引入兩個重要的概念,一個是鎖 mutex,一個是條件變量 condition。鎖很好理解,加鎖的意思就是其他線程不能進入;條件變量則是在多個線程需要交互的情況下,用來線程間同步的原語。

//定義一個隊列
typedef struct {
    int number;  //隊列裏的描述字最大個數
    int *fd;     //這是一個數組指針,隊列本體
    int front;   //當前隊列的頭位置
    int rear;    //當前隊列的尾位置
    pthread_mutex_t mutex;  //鎖
    pthread_cond_t cond;    //條件變量
} block_queue;

//初始化隊列
void block_queue_init(block_queue *blockQueue, int number) {
    blockQueue->number = number;
    blockQueue->fd = calloc(number, sizeof(int));
    blockQueue->front = blockQueue->rear = 0;
    pthread_mutex_init(&blockQueue->mutex, NULL);
    pthread_cond_init(&blockQueue->cond, NULL);
}

//往隊列裏放置一個描述字fd
void block_queue_push(block_queue *blockQueue, int fd) {
    //一定要先加鎖,因爲有多個線程需要讀寫隊列
    pthread_mutex_lock(&blockQueue->mutex);
    //將描述字放到隊列尾的位置
    blockQueue->fd[blockQueue->rear] = fd;
    //如果已經到最後,重置尾的位置
    if (++blockQueue->rear == blockQueue->number) {
        blockQueue->rear = 0;
    }
    printf("push fd %d", fd);
    //通知其他等待讀的線程,有新的連接字等待處理
    pthread_cond_signal(&blockQueue->cond);
    //解鎖
    pthread_mutex_unlock(&blockQueue->mutex);
}

//從隊列裏讀出描述字進行處理
int block_queue_pop(block_queue *blockQueue) {
    //加鎖
    pthread_mutex_lock(&blockQueue->mutex);
    //判斷隊列裏沒有新的連接字可以處理,就一直條件等待,直到有新的連接字入隊列
    //這是爲了確保被pthread_cond_wait喚醒之後的線程,確實可以滿足繼續往下執行的條件。如果沒有while循環的再次確認,可能直接就往下執行了。
    while (blockQueue->front == blockQueue->rear)
        pthread_cond_wait(&blockQueue->cond, &blockQueue->mutex);
    //取出隊列頭的連接字
    int fd = blockQueue->fd[blockQueue->front];
    //如果已經到最後,重置頭的位置
    if (++blockQueue->front == blockQueue->number) {
        blockQueue->front = 0;
    }
    printf("pop fd %d", fd);
    //解鎖
    pthread_mutex_unlock(&blockQueue->mutex);
    //返回連接字
    return fd;
}  

服務端程序如下:

typedef struct {
    pthread_t thread_tid; /* thread ID */
    long thread_count; /* # connections handled */
} Thread;

void thread_run(void *arg) {
    pthread_t tid = pthread_self();
    pthread_detach(tid);

    block_queue *blockQueue = (block_queue *) arg;
    while (1) {
        int fd = block_queue_pop(blockQueue);
        printf("get fd in thread, fd==%d, tid == %d", fd, tid);
        loop_echo(fd);
    }
}

int main(int c, char **v) {
    struct servaddr_in  serv_addr;
    int listener_fd = socket(PF_INET, SOCK_STREAM, 0);
 
    bzero(&serv_addr, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_port = htons(7878);
    serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    int on = 1;
    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
    bind(listen_fd, (struct sockaddr*) &serv_addr, sizeof(serv_addr));
    listen(listen_fd, SOMAXCONN);

    block_queue blockQueue;
    block_queue_init(&blockQueue, BLOCK_QUEUE_SIZE);

    thread_array = calloc(THREAD_NUMBER, sizeof(Thread));
    int i;
    for (i = 0; i < THREAD_NUMBER; i++) {
        pthread_create(&(thread_array[i].thread_tid), NULL, &thread_run, (void *) &blockQueue);
    }

    while (1) {
        struct sockaddr_storage ss;
        socklen_t slen = sizeof(ss);
        int fd = accept(listener_fd, (struct sockaddr *) &ss, &slen);
        if (fd < 0) {
            printf("accept failed\n");
            continue;
        } else {
            block_queue_push(&blockQueue, fd);
        }
    }

    return 0;
}

連接字隊列的實現裏,有一個重要情況沒有考慮,就是隊列滿了。不過和前面的程序相比,線程創建和銷燬的開銷大大降低,但因爲線程池大小固定,又因爲使用了阻塞套接字,肯定會出現有連接得不到及時服務的場景。這個問題的解決還是要回利用多路 I/O 複用加上線程來處理,僅僅使用阻塞 I/O 模型和線程是沒有辦法達到極致的高併發處理能力。

 

溫故而知新 !

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章