C++高并發(fā)三板斧:多進(jìn)程、多線程、IO多路復(fù)用
在日常生活里,我們常常會(huì)進(jìn)行多任務(wù)處理。就像你一邊在電腦上用 Word 寫報(bào)告,一邊聽(tīng)著音樂(lè),同時(shí)微信還在接收消息。這時(shí)候,電腦看似同時(shí)在執(zhí)行多個(gè)任務(wù),其實(shí)就是一種并發(fā)的體現(xiàn)。在編程領(lǐng)域,并發(fā)編程同樣有著至關(guān)重要的地位。對(duì)于 C++ 編程而言,并發(fā)編程能夠顯著提升程序的性能和響應(yīng)速度。在服務(wù)器開(kāi)發(fā)中,服務(wù)器需要同時(shí)處理大量客戶端的請(qǐng)求,如果采用并發(fā)編程技術(shù),就可以讓服務(wù)器在同一時(shí)間內(nèi)響應(yīng)多個(gè)請(qǐng)求,大大提高了服務(wù)器的吞吐量和效率。
在游戲開(kāi)發(fā)里,并發(fā)編程也發(fā)揮著關(guān)鍵作用,游戲中需要同時(shí)處理玩家的操作、畫面的渲染、物理效果的模擬等多個(gè)任務(wù),并發(fā)編程能夠讓這些任務(wù)并行執(zhí)行,從而為玩家?guī)?lái)更加流暢和真實(shí)的游戲體驗(yàn)。并發(fā)編程在 C++ 的眾多應(yīng)用領(lǐng)域中都有著不可或缺的地位,接下來(lái)就讓我們深入探索 C++ 并發(fā)編程中的多進(jìn)程、多線程和 IO 多路復(fù)用技術(shù)。
Part1.多進(jìn)程:獨(dú)立運(yùn)行的 “小世界”
1.1多進(jìn)程是什么?
多進(jìn)程,簡(jiǎn)單來(lái)說(shuō),就是一個(gè)程序同時(shí)運(yùn)行多個(gè)獨(dú)立的任務(wù),每個(gè)任務(wù)都由一個(gè)進(jìn)程來(lái)負(fù)責(zé)。在操作系統(tǒng)中,進(jìn)程是資源分配的最小單位,它擁有獨(dú)立的內(nèi)存空間、系統(tǒng)資源(如文件描述符、信號(hào)處理等)以及獨(dú)立的執(zhí)行環(huán)境 。這就好比在一個(gè)小區(qū)里,每個(gè)房子都有自己獨(dú)立的空間、設(shè)施,住戶在自己的房子里生活,互不干擾。進(jìn)程之間也是如此,它們各自獨(dú)立運(yùn)行,互不影響。
1.2創(chuàng)建進(jìn)程的魔法:fork () 函數(shù)
在 Linux 系統(tǒng)中,我們可以使用 fork () 函數(shù)來(lái)創(chuàng)建新的進(jìn)程。fork () 函數(shù)的作用是復(fù)制當(dāng)前進(jìn)程,生成一個(gè)子進(jìn)程。這個(gè)子進(jìn)程幾乎是父進(jìn)程的一個(gè)副本,它擁有與父進(jìn)程相同的代碼、數(shù)據(jù)和文件描述符等。
fork () 函數(shù)的原理并不復(fù)雜。當(dāng)父進(jìn)程調(diào)用 fork () 時(shí),操作系統(tǒng)會(huì)為子進(jìn)程分配一個(gè)新的進(jìn)程控制塊(PCB),用于管理子進(jìn)程的相關(guān)信息。子進(jìn)程會(huì)繼承父進(jìn)程的大部分資源,包括內(nèi)存空間的映射(但有寫時(shí)復(fù)制機(jī)制,后面會(huì)詳細(xì)介紹)、打開(kāi)的文件描述符等。
fork () 函數(shù)有一個(gè)獨(dú)特的返回值特性:在父進(jìn)程中,它返回子進(jìn)程的進(jìn)程 ID(PID);而在子進(jìn)程中,它返回 0。通過(guò)這個(gè)返回值,我們可以區(qū)分當(dāng)前是父進(jìn)程還是子進(jìn)程在執(zhí)行,從而讓它們執(zhí)行不同的代碼邏輯。下面是一個(gè)簡(jiǎn)單的代碼示例:
#include <stdio.h>
#include <unistd.h>
int main() {
pid_t pid;
// 調(diào)用fork()創(chuàng)建子進(jìn)程
pid = fork();
if (pid < 0) {
// fork()失敗
perror("fork failed");
return 1;
} else if (pid == 0) {
// 子進(jìn)程
printf("I am the child process, my PID is %d, my parent's PID is %d\n", getpid(), getppid());
} else {
// 父進(jìn)程
printf("I am the parent process, my PID is %d, my child's PID is %d\n", getpid(), pid);
}
return 0;
}
運(yùn)行這段代碼,你會(huì)看到父進(jìn)程和子進(jìn)程分別輸出不同的信息,證明它們是獨(dú)立運(yùn)行的。
1.3進(jìn)程間通信(IPC)的橋梁
雖然進(jìn)程之間相互獨(dú)立,但在實(shí)際應(yīng)用中,我們常常需要它們之間進(jìn)行通信和數(shù)據(jù)共享。這就需要用到進(jìn)程間通信(IPC,Inter - Process Communication)機(jī)制。常見(jiàn)的 IPC 方式有管道(Pipe)、消息隊(duì)列(Message Queue)、共享內(nèi)存(Shared Memory)等。
①管道(Pipe):管道是一種半雙工的通信方式,數(shù)據(jù)只能單向流動(dòng),而且只能在具有親緣關(guān)系(如父子進(jìn)程)的進(jìn)程間使用。管道可以看作是一個(gè)特殊的文件,它在內(nèi)核中開(kāi)辟了一塊緩沖區(qū),進(jìn)程通過(guò)讀寫這個(gè)緩沖區(qū)來(lái)進(jìn)行通信。例如,在 Linux 系統(tǒng)中,可以使用 pipe () 函數(shù)創(chuàng)建管道。下面是一個(gè)簡(jiǎn)單的父子進(jìn)程通過(guò)管道通信的示例代碼:
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#define BUFFER_SIZE 1024
int main() {
int pipe_fd[2];
pid_t pid;
char buffer[BUFFER_SIZE];
// 創(chuàng)建管道
if (pipe(pipe_fd) == -1) {
perror("pipe creation failed");
return 1;
}
// 創(chuàng)建子進(jìn)程
pid = fork();
if (pid < 0) {
perror("fork failed");
return 1;
} else if (pid == 0) {
// 子進(jìn)程
close(pipe_fd[0]); // 關(guān)閉讀端
const char *message = "Hello from child";
write(pipe_fd[1], message, strlen(message));
close(pipe_fd[1]); // 關(guān)閉寫端
} else {
// 父進(jìn)程
close(pipe_fd[1]); // 關(guān)閉寫端
ssize_t bytes_read = read(pipe_fd[0], buffer, BUFFER_SIZE - 1);
if (bytes_read > 0) {
buffer[bytes_read] = '\0';
printf("Received from child: %s\n", buffer);
}
close(pipe_fd[0]); // 關(guān)閉讀端
}
return 0;
}
②消息隊(duì)列(Message Queue):消息隊(duì)列是一種異步通信機(jī)制,它允許進(jìn)程向隊(duì)列中發(fā)送消息,也可以從隊(duì)列中接收消息。消息隊(duì)列中的消息具有特定的格式,每個(gè)消息都有一個(gè)類型。不同類型的消息可以被不同的進(jìn)程接收,這樣就實(shí)現(xiàn)了多個(gè)進(jìn)程之間的通信。在 Linux 系統(tǒng)中,可以使用 msgget ()、msgsnd () 和 msgrcv () 等函數(shù)來(lái)操作消息隊(duì)列。
③共享內(nèi)存(Shared Memory):共享內(nèi)存是一種高效的 IPC 方式,它允許多個(gè)進(jìn)程共享同一塊物理內(nèi)存空間。進(jìn)程可以直接讀寫共享內(nèi)存中的數(shù)據(jù),而不需要進(jìn)行數(shù)據(jù)拷貝,因此速度非???。但是,由于多個(gè)進(jìn)程共享內(nèi)存,需要注意同步和互斥問(wèn)題,以避免數(shù)據(jù)沖突。在 Linux 系統(tǒng)中,可以使用 shmget ()、shmat () 和 shmdt () 等函數(shù)來(lái)實(shí)現(xiàn)共享內(nèi)存。
1.4多進(jìn)程的優(yōu)缺點(diǎn)剖析
多進(jìn)程在編程中有著獨(dú)特的優(yōu)勢(shì),同時(shí)也存在一些不足。
優(yōu)點(diǎn):
- 進(jìn)程獨(dú)立性:由于每個(gè)進(jìn)程都有獨(dú)立的內(nèi)存空間和執(zhí)行環(huán)境,一個(gè)進(jìn)程的崩潰不會(huì)影響其他進(jìn)程的運(yùn)行。這使得程序更加健壯和穩(wěn)定,特別適合那些對(duì)穩(wěn)定性要求較高的應(yīng)用場(chǎng)景,如服務(wù)器程序。
- 資源分配清晰:進(jìn)程是資源分配的最小單位,操作系統(tǒng)對(duì)進(jìn)程的資源分配和管理相對(duì)簡(jiǎn)單。每個(gè)進(jìn)程可以獨(dú)立地申請(qǐng)和使用系統(tǒng)資源,不會(huì)出現(xiàn)資源競(jìng)爭(zhēng)導(dǎo)致的死鎖等問(wèn)題(當(dāng)然,進(jìn)程間通信時(shí)仍需注意同步)。
缺點(diǎn):
- 進(jìn)程間通信復(fù)雜:雖然有多種 IPC 機(jī)制,但每種機(jī)制都有其使用場(chǎng)景和限制,實(shí)現(xiàn)復(fù)雜的通信邏輯時(shí)難度較大。例如,共享內(nèi)存需要手動(dòng)處理同步和互斥問(wèn)題,否則容易出現(xiàn)數(shù)據(jù)不一致的情況。
- 系統(tǒng)開(kāi)銷大:創(chuàng)建和銷毀進(jìn)程需要操作系統(tǒng)進(jìn)行大量的工作,包括分配和回收內(nèi)存、創(chuàng)建和銷毀 PCB 等,這會(huì)消耗較多的系統(tǒng)資源和時(shí)間。而且,每個(gè)進(jìn)程都有自己獨(dú)立的內(nèi)存空間,導(dǎo)致內(nèi)存占用較大,在系統(tǒng)資源有限的情況下,可能會(huì)影響程序的性能。
在實(shí)際應(yīng)用中,我們需要根據(jù)具體的需求和場(chǎng)景來(lái)權(quán)衡是否使用多進(jìn)程。如果任務(wù)之間需要高度的獨(dú)立性和穩(wěn)定性,且對(duì)資源開(kāi)銷不太敏感,多進(jìn)程是一個(gè)不錯(cuò)的選擇;但如果任務(wù)之間需要頻繁通信和協(xié)作,或者系統(tǒng)資源有限,可能需要考慮其他并發(fā)編程方式。
Part2.多線程:輕量級(jí)的協(xié)作能手
多線程是指在同一個(gè)進(jìn)程內(nèi),存在多個(gè)獨(dú)立的執(zhí)行流,它們可以同時(shí)(并發(fā))執(zhí)行不同的任務(wù) 。與進(jìn)程不同,線程是進(jìn)程的一個(gè)子集,是操作系統(tǒng)進(jìn)行運(yùn)算調(diào)度的最小單位,線程之間共享進(jìn)程的資源,如內(nèi)存空間、文件描述符等。這就好比在一個(gè)房子里,不同的人可以同時(shí)進(jìn)行不同的活動(dòng),有人在看電視,有人在做飯,有人在看書,他們共享房子里的空間、水電等資源。線程之間的這種協(xié)作和共享資源的特性,使得多線程編程在很多場(chǎng)景下能夠提高程序的執(zhí)行效率和響應(yīng)速度。
2.1C++ 中的線程魔法:std::thread
在 C++11 之前,C++ 標(biāo)準(zhǔn)庫(kù)并沒(méi)有提供對(duì)線程的直接支持,開(kāi)發(fā)者需要依賴操作系統(tǒng)特定的 API(如 Windows 下的 CreateThread 和 Linux 下的 pthread 庫(kù))來(lái)進(jìn)行多線程編程,這使得代碼的可移植性較差。C++11 引入了<thread>頭文件,其中的std::thread類為我們提供了一種跨平臺(tái)的線程操作方式,大大簡(jiǎn)化了多線程編程。
使用std::thread創(chuàng)建線程非常簡(jiǎn)單,只需要將一個(gè)可調(diào)用對(duì)象(如函數(shù)、lambda 表達(dá)式或函數(shù)對(duì)象)傳遞給std::thread的構(gòu)造函數(shù)即可。例如,我們可以創(chuàng)建一個(gè)簡(jiǎn)單的線程來(lái)打印一條消息:
#include <iostream>
#include <thread>
// 線程執(zhí)行的函數(shù)
void print_message() {
std::cout << "This is a message from the thread." << std::endl;
}
int main() {
// 創(chuàng)建線程,傳入print_message函數(shù)
std::thread t(print_message);
// 等待線程執(zhí)行完畢
t.join();
return 0;
}
在這個(gè)例子中,std::thread t(print_message)創(chuàng)建了一個(gè)新的線程t,并將print_message函數(shù)作為線程的執(zhí)行體。t.join()的作用是阻塞當(dāng)前線程(在這里是主線程),直到線程t執(zhí)行完畢。這樣可以確保在主線程結(jié)束之前,子線程已經(jīng)完成了它的任務(wù)。
除了傳遞普通函數(shù),我們還可以使用 lambda 表達(dá)式來(lái)創(chuàng)建線程,這樣可以更方便地捕獲和使用外部變量:
#include <iostream>
#include <thread>
int main() {
int value = 42;
// 使用lambda表達(dá)式創(chuàng)建線程
std::thread t([&]() {
std::cout << "The value is: " << value << std::endl;
});
t.join();
return 0;
}
在這個(gè)例子中,lambda 表達(dá)式[&]() { std::cout << "The value is: " << value << std::endl; }捕獲了外部變量value的引用,并在新線程中使用它。
2.2線程同步:避免沖突的規(guī)則
雖然多線程能夠提高程序的執(zhí)行效率,但由于多個(gè)線程共享進(jìn)程的資源,當(dāng)它們同時(shí)訪問(wèn)和修改共享數(shù)據(jù)時(shí),就可能會(huì)出現(xiàn)數(shù)據(jù)競(jìng)爭(zhēng)(Data Race)和不一致的問(wèn)題。例如,假設(shè)有兩個(gè)線程同時(shí)對(duì)一個(gè)共享的整數(shù)變量進(jìn)行加 1 操作,如果沒(méi)有適當(dāng)?shù)耐綑C(jī)制,最終的結(jié)果可能并不是我們期望的。這是因?yàn)樵诙嗑€程環(huán)境下,線程的執(zhí)行順序是不確定的,兩個(gè)線程可能會(huì)同時(shí)讀取變量的值,然后分別進(jìn)行加 1 操作,最后再寫回結(jié)果,這樣就會(huì)導(dǎo)致其中一個(gè)加 1 操作被覆蓋,最終結(jié)果比預(yù)期少 1。
為了解決這些問(wèn)題,我們需要使用線程同步機(jī)制。C++ 標(biāo)準(zhǔn)庫(kù)提供了多種線程同步工具,其中最常用的是互斥鎖(std::mutex)和條件變量(std::condition_variable)。
①互斥鎖(std::mutex):互斥鎖是一種最基本的同步機(jī)制,它就像一把鎖,一次只能被一個(gè)線程持有。當(dāng)一個(gè)線程獲取到互斥鎖后,其他線程如果試圖獲取該鎖,就會(huì)被阻塞,直到持有鎖的線程釋放它。這樣就保證了在同一時(shí)間內(nèi),只有一個(gè)線程能夠訪問(wèn)被保護(hù)的共享資源。
下面是一個(gè)使用互斥鎖保護(hù)共享資源的示例代碼:
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mtx; // 創(chuàng)建一個(gè)互斥鎖
int shared_data = 0; // 共享數(shù)據(jù)
// 線程執(zhí)行的函數(shù)
void increment() {
for (int i = 0; i < 10000; ++i) {
mtx.lock(); // 加鎖
++shared_data; // 訪問(wèn)和修改共享數(shù)據(jù)
mtx.unlock(); // 解鎖
}
}
int main() {
std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
std::cout << "The final value of shared_data is: " << shared_data << std::endl;
return 0;
}
在這個(gè)例子中,mtx.lock()和mtx.unlock()分別用于加鎖和解鎖。在訪問(wèn)共享數(shù)據(jù)shared_data之前,線程會(huì)先獲取互斥鎖,確保沒(méi)有其他線程同時(shí)訪問(wèn);訪問(wèn)結(jié)束后,再釋放鎖,讓其他線程有機(jī)會(huì)獲取。這樣就避免了數(shù)據(jù)競(jìng)爭(zhēng)問(wèn)題,保證了最終結(jié)果的正確性。
②條件變量(std::condition_variable):條件變量通常與互斥鎖配合使用,用于線程之間的通信和同步。它允許線程在某個(gè)條件滿足之前等待,當(dāng)條件滿足時(shí),其他線程可以通知等待的線程繼續(xù)執(zhí)行。例如,在生產(chǎn)者 - 消費(fèi)者模型中,生產(chǎn)者線程生產(chǎn)數(shù)據(jù)后,通過(guò)條件變量通知消費(fèi)者線程有新的數(shù)據(jù)可用;消費(fèi)者線程在沒(méi)有數(shù)據(jù)時(shí),通過(guò)條件變量等待,避免無(wú)效的輪詢。
下面是一個(gè)簡(jiǎn)單的生產(chǎn)者 - 消費(fèi)者模型的示例代碼,展示了條件變量的使用:
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue>
std::mutex mtx;
std::condition_variable cv;
std::queue<int> data_queue; // 共享數(shù)據(jù)隊(duì)列
// 生產(chǎn)者線程函數(shù)
void producer() {
for (int i = 0; i < 10; ++i) {
std::unique_lock<std::mutex> lock(mtx);
data_queue.push(i); // 生產(chǎn)數(shù)據(jù)
lock.unlock();
cv.notify_one(); // 通知一個(gè)等待的消費(fèi)者線程
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
}
// 消費(fèi)者線程函數(shù)
void consumer() {
while (true) {
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, [] { return!data_queue.empty(); }); // 等待數(shù)據(jù)到來(lái)
int data = data_queue.front(); // 消費(fèi)數(shù)據(jù)
data_queue.pop();
lock.unlock();
std::cout << "Consumed: " << data << std::endl;
if (data == 9) break; // 消費(fèi)完所有數(shù)據(jù)后退出
}
}
int main() {
std::thread t1(producer);
std::thread t2(consumer);
t1.join();
t2.join();
return 0;
}
在這個(gè)例子中,cv.wait(lock, [] { return!data_queue.empty(); });表示消費(fèi)者線程在data_queue為空時(shí)等待,直到data_queue不為空(即有數(shù)據(jù)可用)時(shí)才繼續(xù)執(zhí)行。cv.notify_one()則是生產(chǎn)者線程在生產(chǎn)數(shù)據(jù)后,通知一個(gè)等待的消費(fèi)者線程。
2.3多線程的優(yōu)缺點(diǎn)分析
優(yōu)點(diǎn):
- 資源共享與通信方便:線程共享進(jìn)程的資源,這使得線程之間的數(shù)據(jù)共享和通信非常方便。它們可以直接訪問(wèn)進(jìn)程內(nèi)的共享內(nèi)存,無(wú)需像進(jìn)程間通信那樣使用復(fù)雜的 IPC 機(jī)制。例如,在一個(gè)服務(wù)器程序中,多個(gè)線程可以共享同一個(gè)數(shù)據(jù)庫(kù)連接池,方便地進(jìn)行數(shù)據(jù)庫(kù)操作。
- 上下文切換開(kāi)銷小:相比進(jìn)程,線程的上下文切換開(kāi)銷較小。因?yàn)榫€程共享進(jìn)程的資源,在進(jìn)行上下文切換時(shí),只需要保存和恢復(fù)線程的寄存器狀態(tài)等少量信息,而不需要像進(jìn)程切換那樣保存和恢復(fù)整個(gè)進(jìn)程的資源狀態(tài),這使得線程能夠更快速地切換執(zhí)行,提高了程序的并發(fā)性能。
- 提高程序響應(yīng)性:在圖形界面應(yīng)用程序中,多線程可以將耗時(shí)的操作(如文件讀取、網(wǎng)絡(luò)請(qǐng)求等)放在后臺(tái)線程執(zhí)行,而主線程可以繼續(xù)處理用戶界面的更新和響應(yīng),避免界面卡頓,提高用戶體驗(yàn)。
缺點(diǎn):
- 線程同步問(wèn)題:如前面所述,多線程共享資源容易導(dǎo)致數(shù)據(jù)競(jìng)爭(zhēng)和不一致的問(wèn)題,需要使用同步機(jī)制來(lái)解決。然而,不正確地使用同步機(jī)制(如死鎖、鎖粒度不當(dāng)?shù)龋?huì)導(dǎo)致程序出現(xiàn)難以調(diào)試的錯(cuò)誤,增加開(kāi)發(fā)和維護(hù)的難度。
- 編程復(fù)雜度增加:多線程編程需要考慮線程的生命周期管理、同步問(wèn)題、資源競(jìng)爭(zhēng)等,使得程序的邏輯變得更加復(fù)雜。調(diào)試多線程程序也比單線程程序困難得多,因?yàn)榫€程的執(zhí)行順序不確定,問(wèn)題可能難以重現(xiàn)和定位。
- 性能問(wèn)題:雖然多線程在理論上可以提高程序的執(zhí)行效率,但在實(shí)際應(yīng)用中,如果線程數(shù)量過(guò)多,會(huì)導(dǎo)致上下文切換頻繁,消耗大量的 CPU 時(shí)間,反而降低程序的性能。而且,線程之間的同步操作(如加鎖和解鎖)也會(huì)帶來(lái)一定的開(kāi)銷,如果不合理使用,也會(huì)影響程序的性能。
Part3.IO多路復(fù)用:高效的I/O管理術(shù)
3.1什么是 IO 多路復(fù)用
在編程世界里,I/O 操作(如文件讀寫、網(wǎng)絡(luò)通信等)是非常常見(jiàn)的任務(wù)。傳統(tǒng)的 I/O 模型中,一個(gè)線程通常只能處理一個(gè) I/O 操作,如果要處理多個(gè) I/O 操作,就需要?jiǎng)?chuàng)建多個(gè)線程或者進(jìn)程,這會(huì)帶來(lái)資源浪費(fèi)和復(fù)雜度增加的問(wèn)題。
IO 多路復(fù)用(I/O Multiplexing)技術(shù)的出現(xiàn),很好地解決了這個(gè)問(wèn)題。它允許一個(gè)進(jìn)程同時(shí)監(jiān)聽(tīng)多個(gè)文件描述符(File Descriptor,簡(jiǎn)稱 fd,在 Linux 系統(tǒng)中,一切皆文件,文件描述符是內(nèi)核為了高效管理已被打開(kāi)的文件所創(chuàng)建的索引)的 I/O 事件,當(dāng)某個(gè)文件描述符就緒(有數(shù)據(jù)可讀、可寫或有異常發(fā)生)時(shí),進(jìn)程能夠及時(shí)得到通知并進(jìn)行相應(yīng)的處理 。這就好比一個(gè)餐廳服務(wù)員,他可以同時(shí)照顧多桌客人,當(dāng)某一桌客人有需求(比如需要加水、上菜等)時(shí),服務(wù)員能夠及時(shí)響應(yīng),而不是一個(gè)服務(wù)員只服務(wù)一桌客人,造成資源浪費(fèi)。
3.2常見(jiàn)的 IO 多路復(fù)用方式
在 Linux 系統(tǒng)中,常見(jiàn)的 IO 多路復(fù)用方式有 select、poll 和 epoll,它們各自有著不同的特點(diǎn)和適用場(chǎng)景。
①select:select 是最早出現(xiàn)的 IO 多路復(fù)用方式,它通過(guò)一個(gè)select()系統(tǒng)調(diào)用來(lái)監(jiān)視多個(gè)文件描述符的數(shù)組。select()函數(shù)的原型如下:
#include <sys/select.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
參數(shù)說(shuō)明
- nfds:需要監(jiān)聽(tīng)的文件描述符的最大值加 1。
- readfds:需要監(jiān)聽(tīng)讀事件的文件描述符集合。
- writefds:需要監(jiān)聽(tīng)寫事件的文件描述符集合。
- exceptfds:需要監(jiān)聽(tīng)異常事件的文件描述符集合。
- timeout:設(shè)置select函數(shù)的超時(shí)時(shí)間,如果為NULL,則表示一直阻塞等待。
返回值說(shuō)明
- 成功時(shí)返回就緒文件描述符個(gè)數(shù)。
- 超時(shí)時(shí)返回 0。
- 出錯(cuò)時(shí)返回負(fù)值。
使用select時(shí),需要先初始化文件描述符集合,將需要監(jiān)聽(tīng)的文件描述符添加到對(duì)應(yīng)的集合中,然后調(diào)用select函數(shù)。當(dāng)select返回后,通過(guò)檢查返回值和文件描述符集合,判斷哪些文件描述符就緒,進(jìn)而進(jìn)行相應(yīng)的讀寫操作。例如:
#include <iostream>
#include <sys/select.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstring>
#define PORT 8080
#define MAX_CLIENTS 10
int main() {
int server_socket = socket(AF_INET, SOCK_STREAM, 0);
if (server_socket == -1) {
perror("socket creation failed");
return 1;
}
struct sockaddr_in server_addr;
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(PORT);
server_addr.sin_addr.s_addr = INADDR_ANY;
if (bind(server_socket, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
perror("bind failed");
close(server_socket);
return 1;
}
if (listen(server_socket, 3) == -1) {
perror("listen failed");
close(server_socket);
return 1;
}
fd_set read_fds;
FD_ZERO(&read_fds);
FD_SET(server_socket, &read_fds);
int max_fd = server_socket;
while (true) {
fd_set temp_fds = read_fds;
int activity = select(max_fd + 1, &temp_fds, NULL, NULL, NULL);
if (activity == -1) {
perror("select error");
break;
} else if (activity > 0) {
if (FD_ISSET(server_socket, &temp_fds)) {
int client_socket = accept(server_socket, NULL, NULL);
if (client_socket != -1) {
FD_SET(client_socket, &read_fds);
if (client_socket > max_fd) {
max_fd = client_socket;
}
}
}
for (int i = 0; i <= max_fd; ++i) {
if (FD_ISSET(i, &temp_fds) && i != server_socket) {
char buffer[1024] = {0};
int valread = read(i, buffer, sizeof(buffer));
if (valread == -1) {
perror("read failed");
close(i);
FD_CLR(i, &read_fds);
} else if (valread == 0) {
close(i);
FD_CLR(i, &read_fds);
} else {
std::cout << "Received: " << buffer << std::endl;
}
}
}
}
}
close(server_socket);
return 0;
}
這段代碼創(chuàng)建了一個(gè)簡(jiǎn)單的 TCP 服務(wù)器,使用select監(jiān)聽(tīng)新的客戶端連接和客戶端發(fā)送的數(shù)據(jù)。
select 的優(yōu)點(diǎn)是幾乎在所有平臺(tái)上都支持,具有良好的跨平臺(tái)性;缺點(diǎn)是單個(gè)進(jìn)程能夠監(jiān)視的文件描述符數(shù)量有限,在 Linux 上一般為 1024,并且每次調(diào)用select都需要將文件描述符集合從用戶態(tài)拷貝到內(nèi)核態(tài),隨著文件描述符數(shù)量的增大,其復(fù)制和遍歷的開(kāi)銷也會(huì)線性增長(zhǎng)。
②poll:poll 出現(xiàn)的時(shí)間比 select 稍晚,它和 select 在本質(zhì)上沒(méi)有太大差別,也是通過(guò)輪詢的方式來(lái)檢查文件描述符是否就緒。poll函數(shù)的原型如下:
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
參數(shù)說(shuō)明—fds:一個(gè)指向struct pollfd結(jié)構(gòu)體數(shù)組的指針,struct pollfd結(jié)構(gòu)體定義如下:
struct pollfd {
int fd; // 文件描述符
short events; // 等待的事件
short revents; // 實(shí)際發(fā)生的事件
};
- nfds:指定fds數(shù)組中結(jié)構(gòu)體的個(gè)數(shù)。
- timeout:設(shè)置超時(shí)時(shí)間,單位是毫秒。
返回值說(shuō)明:
- 成功時(shí)返回就緒文件描述符個(gè)數(shù)。
- 超時(shí)時(shí)返回 0。
- 出錯(cuò)時(shí)返回負(fù)值。
與 select 相比,poll 沒(méi)有最大文件描述符數(shù)量的限制,并且它將輸入輸出參數(shù)進(jìn)行了分離,不需要每次都重新設(shè)定。但是,poll 同樣存在包含大量文件描述符的數(shù)組被整體復(fù)制于用戶態(tài)和內(nèi)核的地址空間之間的問(wèn)題,其開(kāi)銷隨著文件描述符數(shù)量的增加而線性增大。
③epoll:epoll 是在 Linux 2.6 內(nèi)核中引入的,它被公認(rèn)為是 Linux 下性能最好的多路 I/O 就緒通知方法。
epoll 有三個(gè)主要函數(shù):
⑴epoll_create:用于創(chuàng)建一個(gè) epoll 實(shí)例,返回一個(gè) epoll 專用的文件描述符。
#include <sys/epoll.h>
int epoll_create(int size);
這里的size參數(shù)在 Linux 2.6.8 版本之后被忽略,但仍需傳入一個(gè)大于 0 的值。
⑵epoll_ctl:用于控制某個(gè) epoll 實(shí)例監(jiān)聽(tīng)的文件描述符,比如添加、刪除或修改監(jiān)聽(tīng)事件。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
參數(shù)說(shuō)明:
- epfd:epoll 實(shí)例的文件描述符。
- op:操作類型,有EPOLL_CTL_ADD(添加)、EPOLL_CTL_MOD(修改)、EPOLL_CTL_DEL(刪除)。
- fd:要操作的文件描述符。
event:指向struct epoll_event結(jié)構(gòu)體的指針,用于設(shè)置監(jiān)聽(tīng)的事件和關(guān)聯(lián)的數(shù)據(jù),struct epoll_event結(jié)構(gòu)體定義如下:
struct epoll_event {
uint32_t events; // Epoll事件
epoll_data_t data; // 用戶數(shù)據(jù)
};
其中,events可以是EPOLLIN(可讀事件)、EPOLLOUT(可寫事件)等事件的組合;data可以是一個(gè)void*指針,用于關(guān)聯(lián)用戶自定義的數(shù)據(jù)。
⑶epoll_wait:用于等待 epoll 實(shí)例上的事件發(fā)生,返回就緒的事件列表。
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
參數(shù)說(shuō)明
- epfd:epoll 實(shí)例的文件描述符。
- events:用于存儲(chǔ)就緒事件的數(shù)組。
- maxevents:指定events數(shù)組的大小。
- timeout:設(shè)置超時(shí)時(shí)間,單位是毫秒,若為 - 1 則表示一直阻塞。
返回值說(shuō)明
- 成功時(shí)返回就緒事件的個(gè)數(shù)。
- 超時(shí)時(shí)返回 0。
- 出錯(cuò)時(shí)返回負(fù)值。
epoll 使用一個(gè)文件描述符管理多個(gè)描述符,將用戶關(guān)心的文件描述符的事件存放到內(nèi)核的一個(gè)事件表中,這樣在用戶空間和內(nèi)核空間的拷貝只需一次。而且,epoll 采用基于事件的就緒通知方式,當(dāng)某個(gè)文件描述符就緒時(shí),內(nèi)核會(huì)采用類似 callback 的回調(diào)機(jī)制,迅速激活這個(gè)文件描述符,當(dāng)進(jìn)程調(diào)用epoll_wait時(shí)便得到通知,大大提高了效率。
綜上所述,select、poll 和 epoll 各有優(yōu)劣,在實(shí)際應(yīng)用中,我們需要根據(jù)具體的需求和場(chǎng)景來(lái)選擇合適的 IO 多路復(fù)用方式。如果需要跨平臺(tái)支持,且文件描述符數(shù)量較少,select 是一個(gè)不錯(cuò)的選擇;如果需要處理大量的文件描述符,且對(duì)性能要求較高,epoll 則是更好的選擇;而 poll 則處于兩者之間,在一些特定場(chǎng)景下也有其用武之地。
Part4.三者之間的區(qū)別
在 C++ 并發(fā)編程的世界里,多進(jìn)程、多線程和 IO 多路復(fù)用各有千秋,它們就像是三把不同的鑰匙,適用于不同的 “鎖”。
多進(jìn)程適用于需要高度獨(dú)立性和穩(wěn)定性的場(chǎng)景。在服務(wù)器開(kāi)發(fā)中,若服務(wù)器的各個(gè)模塊需要獨(dú)立運(yùn)行,互不干擾,即使某個(gè)模塊崩潰也不能影響其他模塊和整個(gè)服務(wù)器的運(yùn)行,此時(shí)多進(jìn)程就是一個(gè)很好的選擇。像數(shù)據(jù)庫(kù)服務(wù)器,它的不同功能模塊(如查詢處理、事務(wù)管理、存儲(chǔ)管理等)可以分別由不同的進(jìn)程來(lái)負(fù)責(zé),這樣可以保證每個(gè)模塊的獨(dú)立性和穩(wěn)定性。但由于多進(jìn)程的開(kāi)銷較大,創(chuàng)建和銷毀進(jìn)程需要消耗較多的資源和時(shí)間,因此在對(duì)資源和性能要求較高的場(chǎng)景下,使用多進(jìn)程時(shí)需要謹(jǐn)慎考慮。
多線程則更適合那些對(duì)資源共享和通信要求較高,且任務(wù)之間協(xié)作緊密的場(chǎng)景。在圖形界面應(yīng)用程序中,為了保證界面的流暢響應(yīng),同時(shí)進(jìn)行一些耗時(shí)的操作(如數(shù)據(jù)加載、網(wǎng)絡(luò)請(qǐng)求等),就可以利用多線程。將耗時(shí)操作放在后臺(tái)線程執(zhí)行,主線程則專注于處理用戶界面的更新和響應(yīng),這樣可以大大提高用戶體驗(yàn)。然而,多線程編程需要注意線程同步和資源競(jìng)爭(zhēng)的問(wèn)題,以避免出現(xiàn)數(shù)據(jù)不一致和死鎖等錯(cuò)誤。
IO 多路復(fù)用主要用于處理大量并發(fā)的 I/O 操作。在高并發(fā)的網(wǎng)絡(luò)服務(wù)器中,服務(wù)器需要同時(shí)處理大量客戶端的連接和請(qǐng)求,如果為每個(gè)客戶端連接都創(chuàng)建一個(gè)線程或進(jìn)程,會(huì)消耗大量的系統(tǒng)資源,導(dǎo)致性能下降。而使用 IO 多路復(fù)用技術(shù),服務(wù)器可以通過(guò)一個(gè)線程同時(shí)監(jiān)聽(tīng)多個(gè)客戶端連接的 I/O 事件,當(dāng)某個(gè)連接有數(shù)據(jù)可讀或可寫時(shí),再進(jìn)行相應(yīng)的處理,這樣可以大大提高服務(wù)器的并發(fā)處理能力和資源利用率。例如,像 Nginx 這樣的高性能 Web 服務(wù)器,就廣泛使用了 epoll 這種 IO 多路復(fù)用技術(shù)來(lái)實(shí)現(xiàn)高效的并發(fā)處理。
在實(shí)際的 C++ 并發(fā)編程中,我們需要根據(jù)具體的需求和場(chǎng)景,綜合考慮多進(jìn)程、多線程和 IO 多路復(fù)用的特點(diǎn),選擇最合適的并發(fā)編程方式,以實(shí)現(xiàn)高效、穩(wěn)定的程序。