Linux 高性能網絡編程 多進程和多線程

在 Linux 網絡編程中,我們應該見過很多網絡框架或者 server,有多進程的處理方式,也有多線程處理方式,孰好孰壞並沒有可比性,首先選擇多進程還是多線程我們需要考慮業務場景,其次結合當前部署環境,是雲原生還是傳統的 IDC 等,最後考慮可維護性,其具體的對比在第三部分具體會展開說。

第一部分:多進程

1、創建一個進程

上面是一個創建進程的函數,那執行當前函數內核會做哪些事情呢?
(1)如果需要創建進程需要調用fork,進程調用 fork,當控制轉移到內核中的 fork 代碼;
(2)內核做分配新的內存塊和內核數據給子進程;
(3)內核將父進程部分數據結構內容拷貝進子進程,有一部分使用寫時複製(copy on write)和父進程共享;
(4)添加子進程到系統進程列表中,同時父進程打開的文件描述符默認在子進程也會打開,且描述符引用計數加 1;
(5)fork返回,內核調度器開始調度,因此fork之後,變成兩個執行流;

2、進程的生成周期

進程創建子進程,當子進程結束以後會出現兩種情況。
(1)如果父進程還在,子進程退出到父進程讀取狀態之前,這段時間爲殭屍態,之後父進程可以調用以下函數等待:

#include <sys/types.h>
#include <sys/wait.h>

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

// 代碼樣例
...
pid_t pid;
int stat;
while ((pid = waitpid(-1, &stat, WNOHANG)) > 0) { // 非阻塞等待
    ...
}
...

(2)如果父進程不在,此時子進程會被 init 進程接管,並等待結束,如果此時子進程一直不退出,就會一直佔用內核資源;

3、進程間通訊

在多進程編程模式中,各個進程不是孤立的,需要處理進程間通訊(IPC),如果您已經有所瞭解可以一起溫故。

(1)管道
管道通訊方式在前面已經講過,通過pipe系統函數創建 fd[0] 和 fd[1],其中兩個句柄就可以提供給父進程和子進程寫入或者讀出數據。

(2)信號量
信號量是爲了解決訪問臨界區提供的一種特殊變量,支持兩種操作:等待和信號,也就是對應 P(進入臨界區),V(退出臨界區);
假設現在有信號量 SV,其執行:

Linux 系統 API 如下:

#include <sys/sem.h>

int semget(key_t key, int nums, int sem_flags);
int semop(int sem_id, struct sembuf *sem_ops, size_t num_sem_ops);
int semctl(int sem_id, int sem_num, int command, ...);

semget創建信號量,semop操作信號量,對應 PV 操作,semctl允許對信號量直接控制,爲了方便大家理解,在此給一段代碼。

...
// op == -1:執行P操作,op == 1:執行V操作
void pv(int sem_id, int op) {
    struct sembuf sem;
    sem.sem_num = 0;
    sem.sem_op = op;
    sem,sem_flg = SEM_UNDO;
    semop(sem_id, &sem, 1);
}

int main(...) {
    int sem_id = semget(IPC_PRIVATE, 1, 0666);
    ...
    pid_t pid = fork();
    if (id == 0) {
        ... 
        pv(sem_id, -1); // 執行P操作
        ...
        pv(sem_id, 1); // 執行V操作
        ...
    } else {
        ... 
        pv(sem_id, -1);
        ...
        pv(sem_id, 1);
        ...
    }
}

(3)共享內存
共享內存是在有些場景下,父進程和子進程需要讀寫大塊的數據,因此 Linux 系統提供了shmgetshmatshmdtshmctl四個系統調用。

shmget創建共享內存或者獲取已存在的共享內存,key標識全局唯一共享內存,size爲設置共享內存大小,shmflg設置的一些宏;
shmat共享內存被創建以後,不能直接訪問,需要關聯到進程的地址空間中,可以設置shm_addr = NULL由操作系統選擇;
shm_openopen調用類似,是 POSIX 方法,創建一個共享內存對象,返回句柄與 mmap 調用;
shm_unlink刪除共享內存標記;
爲了方便大家理解,在此給一段代碼:

...
shmfd = shm_open("xxxx", O_CREAT | O_RDWR, 0666);
share_mem = (char *)mmap(NULL, BUFFER_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, shmfd, 0);
...

注意:共享內存需要考慮多寫多讀的問題,如果多個進程寫,需要加鎖處理。

(4)消息隊列

#include <sys/msg.h>

int msgget(key_t key, int msgflg);
int msgsnd(int msgid, const void * msg_ptr, size_t msg_size, int msgflg);
int msgrcv(int msgid, void * msg_ptr, size_t msg_sz, long int msgtype, int msgflg);
int msgctl(int msgid, int command, struct msgid_ds * buf);

msgget創建消息隊列,key標識全局唯一,msgflg和其他 IPC 的參數類似;
msgsndmsgrcv是發送和寫入消息類型的數據;
爲了方便大家理解,在此給一段代碼:

...

struct msg_buf
{
    long int msg_type;
    char text[BUFSIZ];
};
 
int main(int argc, char **argv)
{
    int msgid = -1;
    struct msg_buf data;
    long int msgtype = 0;
 
    // 建立消息隊列
    msgid = msgget((key_t)1234, 0666 | IPC_CREAT);
    ...
 
    // 從隊列中獲取消息
    while (1)
    {
        if (msgrcv(msgid, (void *)&data, BUFSIZ, msgtype, 0) == -1)
        {
            // ...
        }
        // 遇到end結束
        if (strncmp(data.text, "end", 3) == 0)
        {
            break;
        }
    }
    // 刪除消息隊列
    if (msgctl(msgid, IPC_RMID, 0) == -1)
    {
        ...
    }
    ...
}

4、如何在網絡編程中使用多進程

在多進程的網絡編程中,實現方式有很多,但是總體還是圍繞兩條線,其一如何將新建連接分發給子進程,其二如何將數據 / 信號傳給子進程,並監控子進程,下圖是其實現方式之一(由於實現細節很多,後續會將實現代碼開源到 github):

多進程

(1)首先爲了性能考慮,進程池是必須的,通過線程池不需要頻繁創建和銷燬進程;
(2)其次主進程accept對應的新連接,考慮各個進程之間負載均衡,將新連接通過隨機算法分發給子進程;
(3)分發方式可以通過管道,共享內存,消息隊列等方式告知子進程,也可以傳遞數據信息;
(4)子進程收到新連接的句柄,就可以通過內部的epoll監聽 IO 事件,從而完成sendrecv

第二部分:多線程

1、概述

在 Linux 中,線程是輕量級進程,運行在內核空間,由內核調度,最開始的線程庫是linuxThreads,但是linuxThreads不符合 POSIX 標準,後來出現了 NGPT 和 NPTL,其採用的線程模型不一樣,所以性能有差異,性能由快到慢是:NPTL > NGPT > linuxThreads

其中線程的模型分爲三種:

現在 Linux 的 2.6 內核版本開始,默認使用 NPTL 線程庫(1:1 的線程模型),對比linuxThreads有如下優勢:

2、線程 API

#include <pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);
void pthread_exit(void *retval);
int pthread_join(pthread_t thread, void **retval);
int pthread_cancel(pthread_t thread);
int pthread_detach(pthread_t thread);
pthread_t pthread_self();

(1)pthread_create創建線程,thread表示線程 ID,attr表示設置線程屬性,另外傳遞線程處理函數start_routine和參數arg
(2)pthread_exit線程退出,可以在start_routine執行完成以後調用;
(3)pthread_join是等待線程結束,調用成功返回 0,否則返回錯誤;
(4)pthread_cancel異常終止一個線程;
(5)pthread_detach把指定的線程轉變爲脫離狀態,線程有兩種屬性,一種是 joinable,一種是 detached,當一個 joinable 線程終止時,它的線程 ID 和退出狀態將留存到另一個線程對它調用 pthread_join,調用前線程的資源不會釋放,而脫離 detached 線程終止時,資源會立刻釋放;
(6)pthread_self獲取當前線程 ID;
爲了方便大家理解,在此給一段代碼(使用 c++11 語法,底層是以上 API 的封裝):

#include<iostream>
#include<pthread.h>
#include<thread>

void func(void *arg)
{
    std::cout << "threadid: " << pthread_self() << ", arg: " << *(int*)arg << std::endl;
}

int main()
{
    int i = 1;
    std::thread t1(func, &i);
    t1.join();

    ++i;
    std::thread t2(func, &i);
    t2.join();    
}

3、線程間通訊

(1)信號量

#include <semaphore.h>

int sem_init(sem_t *sem, int pshared, unsigned int value);
int sem_destory(sem_t *sem);
int sem_wait(sem_t *sem);
int sem_trywait(sem_t *sem);
int sem_post(sem_t *sem);

這裏的 API 和多進程的信號量類似,就不展開詳細說了,其中 PV 操作對應的函數是sem_wait信號量減 1,sem_post信號量加 1;

(2)互斥鎖
互斥鎖是線程獨佔臨界區的控制方式,通過以下系統 API:

#include <pthread.h>

int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *mutexattr);
int pthread_mutex_destory(pthread_mutex_t *mutex);
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);

pthread_mutex_init是鎖mutex的初始化,mutexattr爲設置鎖屬性,主要是類型:

pthread_mutex_lockpthread_mutex_unlock成對出現,這裏要注意的是對於非嵌套鎖,一定要注意死鎖場景,另外不要對pthread_mutex_destory執行後的鎖再執行加鎖或者解鎖操作;

(3)條件變量
條件變量是一種線程間通訊機制,當某個共享數據達到某個值得時候,喚醒等待該數據的線程繼續執行,其 API 如下:

#include <pthread.h>

int pthread_cond_init(pthread_cont_t *cond, const pthread_contattr_t* cond_attr);
int pthread_cond_destory(pthread_cont_t *cond);
int pthread_cond_broadcast(pthread_cont_t *cond);
int pthread_cond_signal(pthread_cont_t *cond);
int pthread_cond_wait(pthread_cont_t *cond, pthread_mutex_t* mutex);

pthread_cond_init初始化條件變量condpthread_cond_destory銷燬條件變量和釋放佔用內核資源,pthread_cond_broadcast廣播喚醒所有等待cond的線程;
pthread_cond_signal喚醒一個等待cond的線程,至於哪個被喚醒,取決於線程優先級和調度策略;
其中以上兩個等待的函數是pthread_cond_wait,可能大家有點奇怪,爲啥pthread_cond_wait需要帶一個鎖呢?這是mutex確保pthread_cond_wait操作的原子性,調用pthread_cond_wait之前需要將mutex加鎖,pthread_cond_wait執行時候,首先會把調用線程放入條件變量的等待隊列中,然後將mutex解鎖,等pthread_cond_wait返回成功後,對mutex繼續加鎖,後續處理交給各自線程;

4、如何在網絡編程中使用多線程

與多進程對比,多線程的處理方式相對就簡單很多,由於在多線程內部數據是共享的,所以沒有繁瑣的數據傳遞,只需要隊列就可以完成主線程和子線程之間的數據通信,下圖是其實現方式之一(由於實現細節很多,後續會將實現代碼開源到 github):

多線程

(1)和進程一樣,爲了性能考慮,線程池是必須的,這樣對於 IO 密集型場景,處理線程一般是跑不滿的;
(2)主線程accept對應的新連接,將新連接插入queue,同時通過信號量或條件變量或互斥鎖告知線程池中的線程;
(3)線程池的線程收到通知,先開始搶鎖,然後從隊列中取出新連接;
(4)子線程拿到新連接的句柄,就可以通過內部的epoll監聽 IO 事件,從而完成sendrecv

第三部分:多進程和多線程之爭

在雲原生時代之前,多進程和多線程的網絡框架的爭論已久,每個開發者選擇都有自己的考慮,比如多進程代表的 web server 是 Nginx,Apache 等,多線程的有 Varnish,gRPC,libevent 庫等等,到底該如何選擇網絡框架呢?
(1)首先結合最大化利用多個處理器的硬件結構和軟件架構,在大多數情況下,選擇多線程或多進程處理,又或者兩者兼用都能實現,但是這個選擇將影響軟件的性能、後期的維護、可擴展性、內存等各方面,所以開發網絡框架之前一定要綜合考慮;
(2)考慮多線程的優缺點:

(3)考慮多進程的優缺點:

以上的考慮是基於雲原生時代之前,隨着容器化的到來,我們應遵循 "每個容器一個應用程序" 的原則,原因如下:

以上是結合知乎大佬們的實踐和我個人的工程實踐一些總結,僅供參考。實際選擇和開發過程中,希望開發者更多結合業務場景來選擇和設計網絡框架。

思考

從整篇文章讀下來,讀者應該已經系統性的瞭解了多進程和多線程,老規矩那就提幾個思考題(下一章會解答當前問題):
(1)如果在多線程程序中 fork() 子進程,會發生什麼,我們要考慮那些問題?
(2)在多線程程序中,某個線程掛了,整個進程會掛麼?
(3)如果需要將進程信號發送給某個線程,該如何處理?

參考

(1)https://www.zhihu.com/search?type=content&q=linux%E4%B8%8B%E5%A4%9A%E8%BF%9B%E7%A8%8B%E5%92%8C%E5%A4%9A%E7%BA%BF%E7%A8%8B
(2)畫圖工具:https://excalidraw-cn-1251014631.cos-website.ap-nanjing.myqcloud.com/
(3)《深入解析高性能服務器編程》

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/qN-O5byBrlBu1LuLySutyA