在Linux網(wǎng)絡(luò)編程中,我們應(yīng)該見過很多網(wǎng)絡(luò)框架或者server,有多進程的處理方式,也有多線程處理方式,孰好孰壞并沒有可比性,首先選擇多進程還是多線程我們需要考慮業(yè)務(wù)場景,其次結(jié)合當前部署環(huán)境,是云原生還是傳統(tǒng)的IDC等,最后考慮可維護性,其具體的對比在第三部分具體會展開說。
第一部分:多進程
1、創(chuàng)建一個進程
#include < unistd.h >
pid_t fork(void);
// 返回值:子進程返回0,父進程返回子進程的pid,出錯返回-1。
上面是一個創(chuàng)建進程的函數(shù),那執(zhí)行當前函數(shù)內(nèi)核會做哪些事情呢?
(1)如果需要創(chuàng)建進程需要調(diào)用fork
,進程調(diào)用fork,當控制轉(zhuǎn)移到內(nèi)核中的fork代碼;
(2)內(nèi)核做分配新的內(nèi)存塊和內(nèi)核數(shù)據(jù)給子進程;
(3)內(nèi)核將父進程部分數(shù)據(jù)結(jié)構(gòu)內(nèi)容拷貝進子進程,有一部分使用寫時復(fù)制(copy on write)和父進程共享;
(4)添加子進程到系統(tǒng)進程列表中,同時父進程打開的文件描述符默認在子進程也會打開,且描述符引用計數(shù)加1;
(5)fork
返回,內(nèi)核調(diào)度器開始調(diào)度,因此fork
之后,變成兩個執(zhí)行流;
2、進程的生成周期
進程創(chuàng)建子進程,當子進程結(jié)束以后會出現(xiàn)兩種情況。
(1)如果父進程還在,子進程退出到父進程讀取狀態(tài)之前,這段時間為僵尸態(tài),之后父進程可以調(diào)用以下函數(shù)等待:
#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進程接管,并等待結(jié)束,如果此時子進程一直不退出,就會一直占用內(nèi)核資源;
3、進程間通訊
在多進程編程模式中,各個進程不是孤立的,需要處理進程間通訊(IPC),如果您已經(jīng)有所了解可以一起溫故。
(1)管道
管道通訊方式在前面已經(jīng)講過,通過pipe
系統(tǒng)函數(shù)創(chuàng)建fd[0]和fd[1],其中兩個句柄就可以提供給父進程和子進程寫入或者讀出數(shù)據(jù)。
(2)信號量
信號量是為了解決訪問臨界區(qū)提供的一種特殊變量,支持兩種操作:等待和信號,也就是對應(yīng)P(進入臨界區(qū)),V(退出臨界區(qū));
假設(shè)現(xiàn)在有信號量SV,其執(zhí)行:
- P(SV),如果
SV > 0
,SV將減1;如果SV == 0
,掛起的當前進程; - V(SV),如果有等待SV的進程則喚醒,如果沒有則SV將加1;
Linux系統(tǒng)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
創(chuàng)建信號量,semop
操作信號量,對應(yīng)PV操作,semctl
允許對信號量直接控制,為了方便大家理解,在此給一段代碼。
...
// op == -1:執(zhí)行P操作,op == 1:執(zhí)行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); // 執(zhí)行P操作
...
pv(sem_id, 1); // 執(zhí)行V操作
...
} else {
...
pv(sem_id, -1);
...
pv(sem_id, 1);
...
}
}
(3)共享內(nèi)存
共享內(nèi)存是在有些場景下,父進程和子進程需要讀寫大塊的數(shù)據(jù),因此Linux系統(tǒng)提供了shmget
,shmat
,shmdt
,shmctl
四個系統(tǒng)調(diào)用。
#include < sys/shm.h >
int shmget(key_t key, size_t size, int shmflg);
void* shmat(int shm_id, const void *shm_addr, int shmflg);
int shmdt(const void* shm_addr);
int shmctl(int shm_id, int command, struct shmid_ds* buf);
int shm_open(const char * name, int oflag, mode_t mode);
int shm_unlink(const char * name);
shmget
創(chuàng)建共享內(nèi)存或者獲取已存在的共享內(nèi)存,key
標識全局唯一共享內(nèi)存,size
為設(shè)置共享內(nèi)存大小,shmflg
設(shè)置的一些宏;shmat
共享內(nèi)存被創(chuàng)建以后,不能直接訪問,需要關(guān)聯(lián)到進程的地址空間中,可以設(shè)置shm_addr = NULL
由操作系統(tǒng)選擇;shm_open
和open
調(diào)用類似,是POSIX方法,創(chuàng)建一個共享內(nèi)存對象,返回句柄與mmap調(diào)用;shm_unlink
刪除共享內(nèi)存標記;
為了方便大家理解,在此給一段代碼:
...
shmfd = shm_open("xxxx", O_CREAT | O_RDWR, 0666);
share_mem = (char *)mmap(NULL, BUFFER_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, shmfd, 0);
...
注意 :共享內(nèi)存需要考慮多寫多讀的問題,如果多個進程寫,需要加鎖處理。
(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
創(chuàng)建消息隊列,key
標識全局唯一,msgflg
和其他IPC的參數(shù)類似;msgsnd
和msgrcv
是發(fā)送和寫入消息類型的數(shù)據(jù);
為了方便大家理解,在此給一段代碼:
...
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結(jié)束
if (strncmp(data.text, "end", 3) == 0)
{
break;
}
}
// 刪除消息隊列
if (msgctl(msgid, IPC_RMID, 0) == -1)
{
...
}
...
}
(5)UNIX域
除了以上的通用的IPC,socket的UNIX域也可以作為進程間通訊,比如使用socket(AF_UNIX, SOCK_STREAM, 0)
,或socketpair
系統(tǒng)調(diào)用,或父進程創(chuàng)建一個127.0.0.1
環(huán)回接口socket server,子進程通過socket client訪問。
4、如何在網(wǎng)絡(luò)編程中使用多進程
在多進程的網(wǎng)絡(luò)編程中,實現(xiàn)方式有很多,但是總體還是圍繞兩條線,其一如何將新建連接分發(fā)給子進程,其二如何將數(shù)據(jù)/信號傳給子進程,并監(jiān)控子進程,下圖是其實現(xiàn)方式之一(由于實現(xiàn)細節(jié)很多,后續(xù)會將實現(xiàn)代碼開源到github):
多進程
(1)首先為了性能考慮,進程池是必須的,通過線程池不需要頻繁創(chuàng)建和銷毀進程;
(2)其次主進程accept
對應(yīng)的新連接,考慮各個進程之間負載均衡,將新連接通過隨機算法分發(fā)給子進程;
(3)分發(fā)方式可以通過管道,共享內(nèi)存,消息隊列等方式告知子進程,也可以傳遞數(shù)據(jù)信息;
(4)子進程收到新連接的句柄,就可以通過內(nèi)部的epoll
監(jiān)聽IO事件,從而完成send
和recv
;
第二部分:多線程
1、概述
在Linux中,線程是輕量級進程,運行在內(nèi)核空間,由內(nèi)核調(diào)度,最開始的線程庫是linuxThreads
,但是linuxThreads
不符合POSIX標準,后來出現(xiàn)了NGPT和NPTL,其采用的線程模型不一樣,所以性能有差異,性能由快到慢是:NPTL > NGPT > linuxThreads
。
其中線程的模型分為三種:
- 多對一(M:1)的用戶級線程模型;
- 一對一(1:1)的內(nèi)核級線程模型:如
linuxThreads
和NPTL
; - 多對多(M:N)的兩極線程模型:如NGPT;
現(xiàn)在Linux的2.6內(nèi)核版本開始,默認使用NPTL線程庫(1:1的線程模型),對比linuxThreads
有如下優(yōu)勢:
- 內(nèi)核線程不再是一個進程,因此避免用進程模擬線程導(dǎo)致的語義問題;
- 摒棄了管理線程,終止線程和回收線程等工作由內(nèi)核完成;
- 一個進程中的線程可以運行在不同的CPU上,可以充分利用多處理器系統(tǒng);
- 線程的同步由內(nèi)核完成,隸屬于不同的進程的線程之間也可以共享互斥鎖,因此可以實現(xiàn)跨進程的線程同步;
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
創(chuàng)建線程,thread
表示線程ID,attr
表示設(shè)置線程屬性,另外傳遞線程處理函數(shù)start_routine
和參數(shù)arg
;
(2)pthread_exit
線程退出,可以在start_routine
執(zhí)行完成以后調(diào)用;
(3)pthread_join
是等待線程結(jié)束,調(diào)用成功返回0,否則返回錯誤;
(4)pthread_cancel
異常終止一個線程;
(5)pthread_detach
把指定的線程轉(zhuǎn)變?yōu)槊撾x狀態(tài),線程有兩種屬性,一種是joinable,一種是detached,當一個joinable線程終止時,它的線程ID和退出狀態(tài)將留存到另一個線程對它調(diào)用pthread_join,調(diào)用前線程的資源不會釋放,而脫離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操作對應(yīng)的函數(shù)是sem_wait
信號量減1,sem_post
信號量加1;
(2)互斥鎖
互斥鎖是線程獨占臨界區(qū)的控制方式,通過以下系統(tǒng)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
為設(shè)置鎖屬性,主要是類型:
PTHREAD_MUTEX_NORMAL
普通鎖,只能在同一個線程加鎖解鎖,但是加鎖不可重入,其他線程不能解鎖當前線程的鎖,否則會導(dǎo)致死鎖或者不可預(yù)期效果;PTHREAD_MUTEX_ERRORCHECK
糾錯鎖,主要提供錯誤檢查;PTHREAD_MUTEX_RECURSIVE
嵌套鎖,允許同一個線程重入加鎖,不過其他線程需要這個鎖,當前鎖的擁有者需要執(zhí)行相應(yīng)次數(shù)的解鎖,對已經(jīng)被其他線程加鎖的嵌套鎖解鎖或者對已經(jīng)解鎖的嵌套鎖再解鎖,都會返回錯誤;PTHREAD_MUTEX_DEFAULT
默認鎖,多次加鎖解鎖等行為是未定義;
pthread_mutex_lock
與pthread_mutex_unlock
成對出現(xiàn),這里要注意的是對于非嵌套鎖,一定要注意死鎖場景,另外不要對pthread_mutex_destory
執(zhí)行后的鎖再執(zhí)行加鎖或者解鎖操作;
(3)條件變量
條件變量是一種線程間通訊機制,當某個共享數(shù)據(jù)達到某個值得時候,喚醒等待該數(shù)據(jù)的線程繼續(xù)執(zhí)行,其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
初始化條件變量cond
,pthread_cond_destory
銷毀條件變量和釋放占用內(nèi)核資源,pthread_cond_broadcast
廣播喚醒所有等待cond
的線程;pthread_cond_signal
喚醒一個等待cond
的線程,至于哪個被喚醒,取決于線程優(yōu)先級和調(diào)度策略;
其中以上兩個等待的函數(shù)是pthread_cond_wait
,可能大家有點奇怪,為啥pthread_cond_wait
需要帶一個鎖呢?這是mutex
確保pthread_cond_wait
操作的原子性,調(diào)用pthread_cond_wait
之前需要將mutex
加鎖,pthread_cond_wait
執(zhí)行時候,首先會把調(diào)用線程放入條件變量的等待隊列中,然后將mutex
解鎖,等pthread_cond_wait
返回成功后,對mutex
繼續(xù)加鎖,后續(xù)處理交給各自線程;
4、如何在網(wǎng)絡(luò)編程中使用多線程
與多進程對比,多線程的處理方式相對就簡單很多,由于在多線程內(nèi)部數(shù)據(jù)是共享的,所以沒有繁瑣的數(shù)據(jù)傳遞,只需要隊列就可以完成主線程和子線程之間的數(shù)據(jù)通信,下圖是其實現(xiàn)方式之一(由于實現(xiàn)細節(jié)很多,后續(xù)會將實現(xiàn)代碼開源到github):
多線程
(1)和進程一樣,為了性能考慮,線程池是必須的,這樣對于IO密集型場景,處理線程一般是跑不滿的;
(2)主線程accept
對應(yīng)的新連接,將新連接插入queue
,同時通過信號量或條件變量或互斥鎖告知線程池中的線程;
(3)線程池的線程收到通知,先開始搶鎖,然后從隊列中取出新連接;
(4)子線程拿到新連接的句柄,就可以通過內(nèi)部的epoll
監(jiān)聽IO事件,從而完成send
和recv
;
第三部分:多進程和多線程之爭
在云原生時代之前,多進程和多線程的網(wǎng)絡(luò)框架的爭論已久,每個開發(fā)者選擇都有自己的考慮,比如多進程代表的web server是Nginx,Apache等,多線程的有Varnish,gRPC,libevent庫等等,到底該如何選擇網(wǎng)絡(luò)框架呢?
(1)首先結(jié)合最大化利用多個處理器的硬件結(jié)構(gòu)和軟件架構(gòu),在大多數(shù)情況下,選擇多線程或多進程處理,又或者兩者兼用都能實現(xiàn),但是這個選擇將影響軟件的性能、后期的維護、可擴展性、內(nèi)存等各方面,所以開發(fā)網(wǎng)絡(luò)框架之前一定要綜合考慮;
(2)考慮多線程的優(yōu)缺點:
- 優(yōu)點:多線程最突出的優(yōu)點是借助變量、對象等,線程之間可以便捷地共享數(shù)據(jù),與主線程進行通信也非常容易;在內(nèi)核部分方面,運行于一個進程中的多個線程,它們彼此之間使用相同的地址空間,啟動一個線程所花費的空間遠遠小于啟動一個進程所花費的空間,而且,線程間彼此切換所需的時間也遠遠小于進程間切換所需要的時間;
- 缺點:如果其中一個線程崩潰,整個應(yīng)用程序?qū)⑦B帶崩潰;在調(diào)試代碼方面,多線程調(diào)試非常困難,往往很多意想不到的bug都是多線程操作不當產(chǎn)生,但是看日志又可能看不出來;在內(nèi)核部分多線程可能導(dǎo)致花費大量時間進行上下文切換,影響性能,比如監(jiān)聽socket后,多個線程同時搶占鎖導(dǎo)致頻繁切換,同時每個線程與主程序共用地址空間,線程內(nèi)存受限于進程內(nèi)存空間;還有一個最大的問題就是寫代碼過程中,必須要考慮鎖的情況,如操作全局變量,臨界區(qū)數(shù)據(jù)等等,往往使代碼的結(jié)構(gòu)比較復(fù)雜;
(3)考慮多進程的優(yōu)缺點:
- 優(yōu)點:一個進程崩潰,并不意味著整個應(yīng)用程序的崩潰,這是多進程開發(fā)的一個顯著優(yōu)勢(內(nèi)核空間進程除外);調(diào)試方便,可以快速從日志或者gdb跟進當前進程的運行狀態(tài);寫代碼需要考慮的鎖更少,比如操作全局變量或者臨界區(qū),使得代碼的整體結(jié)構(gòu)相對簡單;
- 缺點:進程之間的通信或者通知比線程之間復(fù)雜,需要使用到IPC各種方式;在內(nèi)核層面,進程越多對于內(nèi)核調(diào)度會越慢,導(dǎo)致整體性能下降;雖然上面優(yōu)點里面對于進程崩潰更好容錯,但是多個進程運行狀態(tài),需要主進程監(jiān)聽或者周邊程序監(jiān)控,使維護功能增多;
以上的考慮是基于云原生時代之前,隨著容器化的到來,我們應(yīng)遵循"每個容器一個應(yīng)用程序"的原則,原因如下:
- 每個容器中只運行一個應(yīng)用程序,則水平伸縮將變得十分容易;
- 每個容器中只運行一個應(yīng)用程序,升級程序時能夠?qū)⒂绊懛秶刂圃俑〉牧6龋瑯O大增加應(yīng)用程序生命周期管理的靈活性,避免在升級某個服務(wù)時中斷相同容器中的其他進程;
- 每個容器中只運行一個應(yīng)用程序,可以更好的利用云原生的工具,比如監(jiān)控,探測等;
實際選擇和開發(fā)過程中,希望開發(fā)者更多結(jié)合業(yè)務(wù)場景來選擇和設(shè)計網(wǎng)絡(luò)框架。
評論