目錄
🍍函數指針
🌼基礎知識
🐙整體概述
🎂基礎API
sigaction 結構體
sigaction()
sigfillset()
SIGALRM, SIGTERM 信號
alarm()
socketpair()
send()
📕信號通知流程
統一事件源
信號處理機制
🌼源碼分析
信號處理函數
信號通知邏輯
代碼
🍍函數指針
函數指針是指向函數的指針變量。在C和C++中,函數被存儲在內存中的某個位置,函數指針可以指向這個內存位置,從而允許通過指針間接調用函數。
函數指針的聲明方式👇
返回類型 (*指針變量名)(參數列表);
例如,如果有一個函數 void myFunction(int x)
void (*funcPtr)(int); // 聲明一個指向函數的指針變量 funcPtr
funcPtr = &myFunction; // 將函數的地址賦給指針變量
// 通過函數指針調用函數
funcPtr(10); // 調用 myFunction(10)
🌼基礎知識
非活躍?
客戶端(即瀏覽器)與服務器建立連接后,長時間不交換數據,一直占用服務器的文件描述符,導致連接資源浪費
定時事件
固定一段時間后觸發某段代碼,由該代碼處理一個事件
eg: 從內核表刪除事件,并關閉文件描述符,釋放連接資源
定時器
利用結構體 / 其他形式,將多種 定時事件 封裝。
具體的,這里只涉及一種定時事件,即 -- 定期檢測非活躍連接,
這里將該定時事件與連接資源,封裝為一個 結構體定時器
定時器容器
使用某種容器類 數據結構,將上述多個定時器組合起來,便于對定時事件統一管理
比如,項目中使用 升序鏈表 ,將所有定時器串聯起來
🐙整體概述
簡介
- 定時器處理非活動連接是一種機制,用于檢測和關閉長時間沒有活動的客戶端連接
- 定時器會周期性地檢查連接的活動狀態,并在連接超過一定時間沒有任何數據傳輸時,將其標記為非活動連接并關閉
- 具體來說,當客戶端與服務器建立連接后,服務器會啟動一個定時器來監視該連接的活動狀態
- 每當服務器接收到客戶端發送的數據或發送數據給客戶端時,定時器會被重置,表示該連接是活動的
- 如果在一段時間內沒有任何數據傳輸,定時器將超時并關閉該連接
TinyWebServer 中,服務器 主循環 為每一個連接創建一個 定時器,并對每個連接進行定時
此外,升序事件鏈表容器,將所有定時器串聯起來
若 主循環 收到定時通知,則在鏈表中依次執行 定時任務
Linux提供 3 種定時方法👇
- socket 選項 SO_RECVTIMEO 和 SO_SNDTIMEO
- SIGALRM信號
- I / O 復用系統調用的超市參數
TinyWebServer 使用 SIGALRM 信號
具體的,利用 alarm() 函數周期性地觸發 SIGALRM 信號,信號處理函數利用 管道 通知 主循環
主循環 接收到信號后,處理 升序鏈表 的所有定時器
若這段時間內,沒有交換數據,則將該連接關閉,釋放占用的資源
由此可見,定時器處理 非活動連接模塊,分 2 部分:
1)定時方法 與 信號通知流程
2) 定時器 及其 容器設計與定時任務 的處理
總覽
定時方法 + 信號通知流程
涉及 基礎API,信號通知流程,代碼實現
基礎API
sigaction 結構體,SIGALRM 信號, SIGTERM 信號
函數👇
sigaction(),sigfillset(),alarm(),socketpair(),send()
信號通知流程
?統一事件源 + 信號處理機制
🎂基礎API
更好的源碼閱讀體驗~
sigaction 結構體
- sa_handler()?-- 函數指針,指向信號處理函數
- sa_sigaction() -- 信號處理函數,3個參數,獲得關于信號更詳細的信息
- sa_mask -- 信號處理函數執行期間,需要被屏蔽的信號
- sa_falgs -- 信號處理行為
- SA_RESTART:使被信號打斷的系統調用,重新自動發起
- SA_NOCLDSTOP:使父進程,在其子進程 暫停/繼續運行 時,不會收到 SIGCHLD 信號
- SA_NOCLDWAIT:使父進程,在其子進程 退出 時,不會收到 SIGCHLD 信號,此時 子進程 退出不會成為僵尸進程
- SA_NODEFER:使對信號的屏蔽無效,即,在信號處理函數期間,仍能發出這個信號
- SA_RESETHAND:信號處理之后,重新設置為默認的處理方式
- SA_SIGINFO:使用 sa_sigaction 成員,而不是 sa_handler 作為信號處理函數
- sa_restorer -- 一般不使用
// 定義一個結構體sigaction,用于處理信號
struct sigaction {// 指向處理信號的函數指針,接受一個int參數void (*sa_handler)(int);// 指向處理信號的函數指針,接受3個參數void (*sa_sigaction)(int, siginfo_t*, void*);// 信號掩碼sigset_t sa_mask;// 標志位int sa_flags;// 恢復處理程序的函數指針,不接受參數,不返回任何值void (*sa_restorer)(void);
};
sigaction()
- signum? ? 操作的信號
- act? ? 對信號設置新的處理方式
- oldact? ? 信號舊的處理方式
- 返回值,0 成功,-1 有錯誤發生
#include<signal.h>int sigaction(int signum, const struct sigaction *act,struct sigaction *oldact);
sigfillset()
信號集
👆在 Linux 系統中,通常使用?
sigset_t
類型來表示信號集。這個類型通常是一個整數數組,每個元素對應一個信號,用來表示該信號是否被設置
👇將參數 set 信號集 初始化,然后把所有信號加入此信號集
#include<signal.h>int sigfillset(sigset_t *set);
SIGALRM, SIGTERM 信號
#define SIGALRM 14 // alarm 系統調用 產生timer時鐘信號
#define SIGTERM 15 // 終端發送的終止信號
alarm()
設置信號傳送鬧鐘,即,設置信號 SIGALRM,經過參數 seconds 秒后,發送給目前進程
如果未設置信號 SIGALRM 的處理函數,那么 alarm() 默認處理終止進程
#include<unistd.h>unsigned int alarm(unsigned int seconds);
socketpair()
Linux 下,使用 socketpair() 函數,創建一對套接字進行通信,TinyWebServer 使用管道通信
- domain -- 協議族(PF_UNIX 或 AF_UNIX)
- type -- 協議(SOCK_STREAM 或 SOCK_DGRAM),SOCK_STREAM 基于 TCP,SOCK_DGRAM 基于 UDP
- protocol -- 類型(只能為 0)
- sv[2] -- 套接字柄對,兩個句柄作用相同,均可獨寫雙向操作
- 返回結果,0 成功,-1 創建失敗
#include<sys/types.h>
#include<sys/socket.h>
int socketpair(int domain, int type, int protocol, int sv[2]);
send()
套接字 發送緩沖區 變滿時,send 通常會阻塞,除非 套接字 設置成 非阻塞模式
當緩沖區變滿,返回 EAGAIN 或 EWOULDBLOCK 錯誤,此時可調用 select() 函數,來監視何時可以發送數據
#include<sys/types.h>
#include<sys/socket.h>ssize_t send(int sockfd, const void *buf, size_t len, int flags);
📕信號通知流程
Linux 下信號,采用 異步處理機制,信號處理函數 和 當前進程 是 2 條不同的執行路線
異步處理機制👇解釋
在Linux系統中,信號就像是一種突然發生的事件通知,比如按下Ctrl+C鍵發送的中斷信號。當這個事件發生時,操作系統會中斷當前正在進行的工作,去執行與之對應的處理函數,處理完后再回到原來的工作。
這個處理過程是異步的,也就是說,處理信號的函數和當前正在執行的程序是兩條不同的路線。處理函數負責響應信號事件,而當前程序則會在收到信號時被中斷,等待處理完畢后再繼續執行。這樣可以保證及時響應各種突發事件,確保系統的穩定和安全。
當進程收到信號時,操作系統會中斷當前的正常流程,轉而進入信號處理函數執行操作,完成后再返回中斷的地方繼續執行
為了避免 信號競態 的發生,信號處理期間,系統不會再次觸發它
所以,為確保該信號不被屏蔽太久,信號處理函數,需要盡可能快地執行完畢
信號處理函數,需要處理該信號對應的邏輯,當該邏輯較復雜,信號處理函數執行時間過長,會導致信號屏蔽太久
解決方案
信號處理函數,僅發送信號,通知程序主循環,將信號對應的處理邏輯,放在主循環中
由主循環執行信號對應的邏輯代碼
統一事件源
指的是,將 信號事件 與 其他事件 一樣被處理
eg:信號處理函數使用? 管道? 將信號傳遞給? 主循環
信號處理函數往? 管道的寫端? 寫入信號值
主循環則從? 管道的讀端? 讀出信號值
使用 I / O 復用系統調用來監聽? 管道讀端? 的可讀事件
此時,信號事件 與 其他文件描述符 都可以通過 epoll 來監測,從而實現統一處理
信號處理機制
每個進程中,都存在一個表,里面存著每種信號所代表的含義,內核通過設置表項中每一個位,來標識對應的信號類型
- 信號接收
- 接收信號的任務是由 內核 代理的,當內核接收到信號后,會將其放到對應進程的信號隊列中,同時向進程發送一個中斷,使其陷入內核態。注意,此時信號還只是在隊列中,對進程來說,暫時不知道信號到來了
- 信號檢測
- 進程從內核態返回到用戶態前,進行信號檢測
- 進程在內核態中,從睡眠被喚醒時,進行信號檢測
- 進程陷入內核態后,有 2 種場景對信號進行檢測
- 當發現新信號時,會進入下一步,信號的處理
- 信號處理
- (內核)信號處理函數,運行在 用戶態,調用處理函數前,內核會將當前的內核棧的內容備份,拷貝到用戶棧上,并修改指令寄存器(eip),將其指向信號處理函數
- (用戶)接下來,進程返回用戶態,執行相應的信號處理函數
- (內核)信號處理函數 執行完畢后,還要返回內核態,檢查是否還有其他信號未處理
- (用戶)所有信號處理完后,內核棧就會恢復(從用戶棧的備份拷貝),同時恢復指令寄存器(eip),將其指向中斷前的運行位置,最后返回用戶態,繼續執行進程
到此,一個完整的 信號處理流程 就結束了
如果同時有多個信號到達,上面的處理流程,會在第 2 步和第 3 步間循環
🌼源碼分析
信號處理函數
自定義信號處理函數,創建 sigaction 結構體變量,設置信號函數
// 信號處理函數
void sig_handler(int sig)
{// 為保證函數的可重入性,保留原來的errno// 可重入性:中斷后,再次進入該函數,環境變量與之前相同// 不會丟失數據int save_errno = errno;int msg = sig;// 信號值從 管道寫端 寫入,傳輸字符類型,而非 整型send(pipefd[1], (char*)&msg, 1, 0);// 原來的 errno 賦值為當前的 errnoerrno = save_errno;
}
?信號處理函數中,僅通過管道發送信號值,不處理信號對應的邏輯,縮短異步執行時間,減少對主程序影響
void addsig(int sig, void(handler)(int), bool restart = true);
👆解釋
sig
:要注冊的信號。handler
:信號處理函數。restart
:標志,指示是否在中斷系統調用后自動重啟該調用。函數聲明中使用了函數指針作為參數,這意味著
handler
參數必須是一個指向函數的指針,該函數接受一個整數參數并返回void
類型
// 設置信號函數
void addsig(int sig, void(handler)(int), bool restart = true)
{// 創建 sigaction 結構體變量struct sigaction sa;// 起始地址, 初值,字節數memeset(&sa, '\0', sizeof(sa));// 信號處理函數中僅發送 信號值,不做對應邏輯處理sa.sa_handler = handler; // 函數指針// 對結構體變量 按位或,SA_RESTART 標志位變1// 表示需要自動重啟if (restart)sa.sa_flags |= SA_RESTART;// 所有信號添加到 信號集 中sigfillset(&sa.sa_mask);// 執行 sigaction() 函數assert(sigaction(sig, &sa, NULL) != -1);
}
項目中設置信號函數,僅關注 SIGTERM 和 SIGALRM 兩個信號
信號通知邏輯
- 創建管道,管道寫端 寫入信號值,管道讀端 通過 I / O 復用系統 檢測讀事件
- 設置信號處理函數 SIGALRM(時間到了觸發)和 SIGTERM(kill 會觸發,ctrl + c)
- 通過 struct sigaction 結構體 和 sigaction() 函數,注冊信號捕捉函數(結構體和函數進行關聯)
- 在結構體的 handler 參數,設置信號處理函數,具體的,從 管道寫端 寫入信號名字
- 利用 I /O 復用系統,監聽 管道讀端 文件描述符的可讀事件
- 信息值 傳遞給主循環,主循環再根據接收到的 信號值,執行目標信號對應的邏輯代碼
代碼
// 創建管道套接字
ret = socketpair(PF_UNIX, SOCK_STREAM, 0, pipefd);
assert(ret != -1);// 設置管道寫端非阻塞,為什么寫端要非阻塞?
setnonblocking(pipefd[1]);// 設置管道讀端為 ET 非阻塞
addfd(epollfd, pipefd[0], false);// 傳遞給主循環的信號值,這里只關注 SIGALRM 和 SIGTERM
addsig(SIGALRM, sig_handler, false);
addsig(SIGTERM, sig_handler, false);// 循環條件
bool stop__server = false;// 超時標志
bool timeout = false;// 每隔 TIMESLOT 時間,觸發 SIGALRM 信號
alarm(TIMESLOT);while (!stop_server)
{// 監測發生事件的文件描述符int number = epoll_wait(epollfd, events, MAX_EVENT_NUMBER, -1);if (number < 0 && errno != EINTR)break;// 輪詢文件描述符for (int i = 0; i < number; i++) {int sockfd = events[i].data.fd;// 管道讀端 對應的 文件描述符 發生讀事件/*按位與 & 運算符判斷某個特定的標志位是否在 events[i].events 中被設置(是否發生了讀事件)如果結果為真,表示發生了讀事件程序將會執行相應的邏輯來處理該事件*/if ( (sockfd == pipefd[0]) && (events[i].events & EPOLLIN) ){int sig;char signals[1024];// 從 管道讀端 讀出信號值,成功-返回字節數,失敗-返回-1// 一般,這里的ret返回值總是 1// 只有 14,15 兩個 ASCII碼 對應的字符ret = recv(pipefd[0], signals, sizeof(signals), 0);if (ret == -1) // handle the errorcontinue;else if (ret == 0)continue;else {// 處理 信號值 對應的邏輯for (int i = 0; i < ret; ++i) {switch (signals[i]) { // charcase SIGALRM: // int{timeout = true;break;}case SIGTERM:stop_server = true;}}}}}
}
補充解釋
ret = recv(pipefd[0], signals, sizeof(signals), 0);
pipefd[0]
?是管道的讀端文件描述符signals
?是一個緩沖區,用于存儲接收到的數據sizeof(signals)
?表示?signals
?緩沖區的大小,即要接收的數據的最大字節數0
?是可選的參數,用于指定接收數據時的額外標志。在此處,它表示沒有任何特殊的處理要求函數執行過程👇
recv()
?函數阻塞等待,直到管道讀端文件描述符 (pipefd[0]
) 中有數據可讀- 一旦有數據可讀,
recv()
?函數將讀取數據并將其存儲在?signals
?緩沖區中recv()
?函數返回讀取的字節數,并將其賦值給變量?ret
,以便后續處理
問題1:為什么 管道寫端 要 非阻塞?
send() 將信息發送給套接字緩沖區,如果緩沖區滿了,就會阻塞
這時會進一步增加 信號處理函數 的執行時間,為此,將其修改為 非阻塞
👆補充解釋
- 代碼中的
setnonblocking(pipefd[1])
調用將管道寫端設置為非阻塞模式,這意味著寫入管道時不會被阻塞,而是立即返回。這是因為在主循環中,當有事件發生時,程序會將相應的數據寫入管道以觸發 SIGALRM 或 SIGTERM 信號,從而實現定時和停止服務器的功能。如果管道寫端是阻塞的,則當數據無法立即寫入管道時,程序會被阻塞等待,直到數據寫入成功或出現錯誤。這可能會導致定時事件失效或無法及時停止服務器。因此,將管道寫端設置為非阻塞模式是必要的?
問題2:沒有對 非阻塞返回值 處理,如果 阻塞 是不是意味著這一次 定時事件 失效 了?
對,但 定時事件 不是必須立即處理的事件,可以允許這樣的情況發生
👆補充解釋
- 代碼中的管道寫端是非阻塞的,因此如果寫入的數據無法立即發送,則會立即返回,并且不會阻塞等待。如果沒有處理非阻塞返回值,則會導致寫入失敗而沒有得到及時處理。如果這種情況經常發生,則可能會導致定時事件失效,因為無法在規定的時間內將數據寫入管道
- 定時事件不是必須立即處理的事件。在這個例子中,定時事件是通過 SIGALRM 信號實現的,每隔 TIMESLOT 時間就會觸發一次該信號。即使寫入管道失敗,也不會導致 SIGALRM 信號失效,因為下一次定時事件仍然會在規定的時間內觸發。因此,在這種情況下,可以允許寫入管道失敗并且不處理非阻塞返回值
問題3:管道 傳遞的是什么類型?switch-case 的變量沖突?
信號 本身是 整型,管道中傳遞的是 ASCII碼 表中整型對應的字符
switch 的變量,一般為 字符或整型,當 switch 變量為字符,case 中可以為字符,也可以是字符對應的 ASCII 碼