github:https://github.com/froghui/yolanda
I/O模型和多線程模型實現
- 多線程設計的幾個考慮
在我們的設計中,main reactor 線程是一個 acceptor 線程,這個線程一旦創建,會以 event_loop 形式阻塞在 event_dispatcher 的 dispatch 方法上,實際上,它在等待監聽套接字上的事件發生,也就是已完成的連接,一旦有連接完成,就會創建出連接對象 tcp_connection,以及 channel 對象等。
當用戶期望使用多個 sub-reactor 子線程時,主線程會創建多個子線程,每個子線程在創建之後,按照主線程指定的啓動函數立即運行,並進行初始化。隨之而來的問題是,主線程如何判斷子線程已經完成初始化並啓動,繼續執行下去呢?這是一個需要解決的重點問題。
在設置了多個線程的情況下,需要將新創建的已連接套接字對應的讀寫事件交給一個 sub-reactor 線程處理。所以,這裏從 thread_pool 中取出一個線程,通知這個線程有新的事件加入。而這個線程很可能是處於事件分發的阻塞調用之中,如何協調主線程數據寫入給子線程,這是另一個需要解決的重點問題。
子線程是一個 event_loop 線程,它阻塞在 dispatch 上,一旦有事件發生,它就會查找 channel_map,找到對應的處理函數並執行它。之後它就會增加、刪除或修改 pending 事件,再次進入下一輪的 dispatch。
文稿中放置了一張圖,闡述了線程的運行關係:
爲了方便你理解,我把對應的函數實現列在了另外一張圖中。
- 主線程等待多個 sub-reactor 子線程初始化完
主線程需要等待子線程完成初始化,也就是需要獲取子線程對應數據的反饋,而子線程初始化也是對這部分數據進行初始化,實際上這是一個多線程的通知問題。採用的做法在前面提到過,使用 mutex 和 condition 兩個主要武器。
下面這段代碼是主線程發起的子線程創建,調用 event_loop_thread_init 對每個子線程初始化,之後調用 event_loop_thread_start 來啓動子線程。注意,如果應用程序指定的線程池大小爲 0,則直接返回,這樣 acceptor 和 I/O 事件都會在同一個主線程裏處理,就退化爲單 reactor 模式。
//一定是main thread發起
void thread_pool_start(struct thread_pool *threadPool) {
assert(!threadPool->started);
assertInSameThread(threadPool->mainLoop);
threadPool->started = 1;
void *tmp;
if (threadPool->thread_number <= 0) {
return;
}
threadPool->eventLoopThreads = malloc(threadPool->thread_number * sizeof(struct event_loop_thread));
for (int i = 0; i < threadPool->thread_number; ++i) {
event_loop_thread_init(&threadPool->eventLoopThreads[i], i);
event_loop_thread_start(&threadPool->eventLoopThreads[i]);
}
}
我們再看一下 event_loop_thread_start 這個方法,這個方法一定是主線程運行的。這裏我使用了 pthread_create 創建了子線程,子線程一旦創建,立即執行 event_loop_thread_run,我們稍後將看到,event_loop_thread_run 進行了子線程的初始化工作。event_loop_thread_start 最重要的部分是使用了 pthread_mutex_lock 和 pthread_mutex_unlock 進行了加鎖和解鎖,並使用了 pthread_cond_wait 來守候 eventLoopThread 中的 eventLoop 的變量。
//由主線程調用,初始化一個子線程,並且讓子線程開始運行event_loop
struct event_loop *event_loop_thread_start(struct event_loop_thread *eventLoopThread) {
pthread_create(&eventLoopThread->thread_tid, NULL, &event_loop_thread_run, eventLoopThread);
assert(pthread_mutex_lock(&eventLoopThread->mutex) == 0);
while (eventLoopThread->eventLoop == NULL) {
assert(pthread_cond_wait(&eventLoopThread->cond, &eventLoopThread->mutex) == 0);
}
assert(pthread_mutex_unlock(&eventLoopThread->mutex) == 0);
yolanda_msgx("event loop thread started, %s", eventLoopThread->thread_name);
return eventLoopThread->eventLoop;
}
爲什麼要這麼做呢?看一下子線程的代碼你就會大致明白。子線程執行函數 event_loop_thread_run 一上來也是進行了加鎖,之後初始化 event_loop 對象,當初始化完成之後,調用了 pthread_cond_signal 函數來通知此時阻塞在 pthread_cond_wait 上的主線程。這樣,主線程就會從 wait 中甦醒,代碼得以往下執行。子線程本身也通過調用 event_loop_run 進入了一個無限循環的事件分發執行體中,等待子線程 reactor 上註冊過的事件發生。
void *event_loop_thread_run(void *arg) {
struct event_loop_thread *eventLoopThread = (struct event_loop_thread *) arg;
pthread_mutex_lock(&eventLoopThread->mutex);
// 初始化化event loop,之後通知主線程
eventLoopThread->eventLoop = event_loop_init();
yolanda_msgx("event loop thread init and signal, %s", eventLoopThread->thread_name);
pthread_cond_signal(&eventLoopThread->cond);
pthread_mutex_unlock(&eventLoopThread->mutex);
//子線程event loop run
eventLoopThread->eventLoop->thread_name = eventLoopThread->thread_name;
event_loop_run(eventLoopThread->eventLoop);
}
可以看到,這裏主線程和子線程共享的變量正是每個 event_loop_thread 的 eventLoop 對象,這個對象在初始化的時候爲 NULL,只有當子線程完成了初始化,才變成一個非 NULL 的值,這個變化是子線程完成初始化的標誌,也是信號量守護的變量。通過使用鎖和信號量,解決了主線程和子線程同步的問題。當子線程完成初始化之後,主線程纔會繼續往下執行。
struct event_loop_thread {
struct event_loop *eventLoop;
pthread_t thread_tid; /* thread ID */
pthread_mutex_t mutex;
pthread_cond_t cond;
char * thread_name;
long thread_count; /* # connections handled */
};
你可能會問,主線程是循環在等待每個子線程完成初始化,如果進入第二個循環,等待第二個子線程完成初始化,而此時第二個子線程已經初始化完成了,該怎麼辦?注意我們這裏一上來是加鎖的,只要取得了這把鎖,同時發現 event_loop_thread 的 eventLoop 對象已經變成非 NULL 值,可以肯定第二個線程已經初始化,就直接釋放鎖往下執行了。
你可能還會問,在執行 pthread_cond_wait 的時候,需要持有那把鎖麼?這裏,父線程在調用 pthread_cond_wait 函數之後,會立即進入睡眠,並釋放持有的那把互斥鎖。而當父線程再從 pthread_cond_wait 返回時(這是子線程通過 pthread_cond_signal 通知達成的),該線程再次持有那把鎖。
- 增加已連接套接字事件到 sub-reactor 線程中
前面提到,主線程是一個 main reactor 線程,這個線程負責檢測監聽套接字上的事件,當有事件發生時,也就是一個連接已完成建立,如果我們有多個 sub-reactor 子線程,我們期望的結果是,把這個已連接套接字相關的 I/O 事件交給 sub-reactor 子線程負責檢測。這樣的好處是,main reactor 只負責連接套接字的建立,可以一直維持在一個非常高的處理效率,在多核的情況下,多個 sub-reactor 可以很好地利用上多核處理的優勢。
我們知道,sub-reactor 線程是一個無限循環的 event loop 執行體,在沒有已註冊事件發生的情況下,這個線程阻塞在 event_dispatcher 的 dispatch 上。你可以簡單地認爲阻塞在 poll 調用或者 epoll_wait 上,這種情況下,主線程如何能把已連接套接字交給 sub-reactor 子線程呢?
如果我們能讓 sub-reactor 線程從 event_dispatcher 的 dispatch 上返回,再讓 sub-reactor 線程返回之後能夠把新的已連接套接字事件註冊上,這件事情就算完成了。
那如何讓 sub-reactor 線程從 event_dispatcher 的 dispatch 上返回呢?答案是構建一個類似管道一樣的描述字,讓 event_dispatcher 註冊該管道描述字,當我們想讓 sub-reactor 線程甦醒時,往管道上發送一個字符就可以了。
在 event_loop_init 函數裏,調用了 socketpair 函數創建了套接字對,這個套接字對的作用就是我剛剛說過的,往這個套接字的一端寫時,另外一端就可以感知到讀的事件。其實,這裏也可以直接使用 UNIX 上的 pipe 管道,作用是一樣的。
struct event_loop *event_loop_init() {
...
//add the socketfd to event 這裏創建的是套接字對,目的是爲了喚醒子線程
eventLoop->owner_thread_id = pthread_self();
if (socketpair(AF_UNIX, SOCK_STREAM, 0, eventLoop->socketPair) < 0) {
LOG_ERR("socketpair set fialed");
}
eventLoop->is_handle_pending = 0;
eventLoop->pending_head = NULL;
eventLoop->pending_tail = NULL;
eventLoop->thread_name = "main thread";
struct channel *channel = channel_new(eventLoop->socketPair[1], EVENT_READ, handleWakeup, NULL, eventLoop);
event_loop_add_channel_event(eventLoop, eventLoop->socketPair[0], channel);
return eventLoop;
}
要特別注意的是文稿中的這句代碼,這告訴 event_loop 的,是註冊了 socketPair[1]描述字上的 READ 事件,如果有 READ 事件發生,就調用 handleWakeup 函數來完成事件處理。
struct channel *channel = channel_new(eventLoop->socketPair[1], EVENT_READ, handleWakeup, NULL, eventLoop);
事實上,這個函數就是簡單的從 socketPair[1]描述字上讀取了一個字符而已,除此之外,它什麼也沒幹。它的主要作用就是讓子線程從 dispatch 的阻塞中甦醒。
int handleWakeup(void * data) {
struct event_loop *eventLoop = (struct event_loop *) data;
char one;
ssize_t n = read(eventLoop->socketPair[1], &one, sizeof one);
if (n != sizeof one) {
LOG_ERR("handleWakeup failed");
}
yolanda_msgx("wakeup, %s", eventLoop->thread_name);
}
現在,我們再回過頭看看,如果有新的連接產生,主線程是怎麼操作的?在 handle_connection_established 中,通過 accept 調用獲取了已連接套接字,將其設置爲非阻塞套接字(切記),接下來調用 thread_pool_get_loop 獲取一個 event_loop。thread_pool_get_loop 的邏輯非常簡單,從 thread_pool 線程池中按照順序挑選出一個線程來服務。接下來是創建了 tcp_connection 對象。
//處理連接已建立的回調函數
int handle_connection_established(void *data) {
struct TCPserver *tcpServer = (struct TCPserver *) data;
struct acceptor *acceptor = tcpServer->acceptor;
int listenfd = acceptor->listen_fd;
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
//獲取這個已建立的套集字,設置爲非阻塞套集字
int connected_fd = accept(listenfd, (struct sockaddr *) &client_addr, &client_len);
make_nonblocking(connected_fd);
yolanda_msgx("new connection established, socket == %d", connected_fd);
//從線程池裏選擇一個eventloop來服務這個新的連接套接字
struct event_loop *eventLoop = thread_pool_get_loop(tcpServer->threadPool);
// 爲這個新建立套接字創建一個tcp_connection對象,並把應用程序的callback函數設置給這個tcp_connection對象
struct tcp_connection *tcpConnection = tcp_connection_new(connected_fd, eventLoop,tcpServer->connectionCompletedCallBack,tcpServer->connectionClosedCallBack,tcpServer->messageCallBack,tcpServer->writeCompletedCallBack);
//callback內部使用
if (tcpServer->data != NULL) {
tcpConnection->data = tcpServer->data;
}
return 0;
}
在調用 tcp_connection_new 創建 tcp_connection 對象的代碼裏,可以看到先是創建了一個 channel 對象,並註冊了 READ 事件,之後調用 event_loop_add_channel_event 方法往子線程中增加 channel 對象。
tcp_connection_new(int connected_fd, struct event_loop *eventLoop,
connection_completed_call_back connectionCompletedCallBack,
connection_closed_call_back connectionClosedCallBack,
message_call_back messageCallBack, write_completed_call_back writeCompletedCallBack) {
...
//爲新的連接對象創建可讀事件
struct channel *channel1 = channel_new(connected_fd, EVENT_READ, handle_read, handle_write, tcpConnection);
tcpConnection->channel = channel1;
//完成對connectionCompleted的函數回調
if (tcpConnection->connectionCompletedCallBack != NULL) {
tcpConnection->connectionCompletedCallBack(tcpConnection);
}
//把該套集字對應的channel對象註冊到event_loop事件分發器上
event_loop_add_channel_event(tcpConnection->eventLoop, connected_fd, tcpConnection->channel);
return tcpConnection;
}
請注意,到現在爲止的操作都是在主線程裏執行的。下面的 event_loop_do_channel_event 也不例外,接下來的行爲我期望你是熟悉的,那就是加解鎖。如果能夠獲取鎖,主線程就會調用 event_loop_channel_buffer_nolock 往子線程的數據中增加需要處理的 channel event 對象。所有增加的 channel 對象以列表的形式維護在子線程的數據結構中。接下來的部分是重點,如果當前增加 channel event 的不是當前 event loop 線程自己,就會調用 event_loop_wakeup 函數把 event_loop 子線程喚醒。喚醒的方法很簡單,就是往剛剛的 socketPair[0]上寫一個字節,別忘了,event_loop 已經註冊了 socketPair[1]的可讀事件。如果當前增加 channel event 的是當前 event loop 線程自己,則直接調用 event_loop_handle_pending_channel 處理新增加的 channel event 事件列表。
int event_loop_do_channel_event(struct event_loop *eventLoop, int fd, struct channel *channel1, int type) {
//get the lock
pthread_mutex_lock(&eventLoop->mutex);
assert(eventLoop->is_handle_pending == 0);
//往該線程的channel列表裏增加新的channel
event_loop_channel_buffer_nolock(eventLoop, fd, channel1, type);
//release the lock
pthread_mutex_unlock(&eventLoop->mutex);
//如果是主線程發起操作,則調用event_loop_wakeup喚醒子線程
if (!isInSameThread(eventLoop)) {
event_loop_wakeup(eventLoop);
} else {
//如果是子線程自己,則直接可以操作
event_loop_handle_pending_channel(eventLoop);
}
return 0;
}
如果是 event_loop 被喚醒之後,接下來也會執行 event_loop_handle_pending_channel 函數。你可以看到在循環體內從 dispatch 退出之後,也調用了 event_loop_handle_pending_channel 函數。
int event_loop_run(struct event_loop *eventLoop) {
assert(eventLoop != NULL);
struct event_dispatcher *dispatcher = eventLoop->eventDispatcher;
if (eventLoop->owner_thread_id != pthread_self()) {
exit(1);
}
yolanda_msgx("event loop run, %s", eventLoop->thread_name);
struct timeval timeval;
timeval.tv_sec = 1;
while (!eventLoop->quit) {
//block here to wait I/O event, and get active channels
dispatcher->dispatch(eventLoop, &timeval);
//這裏處理pending channel,如果是子線程被喚醒,這個部分也會立即執行到
event_loop_handle_pending_channel(eventLoop);
}
yolanda_msgx("event loop end, %s", eventLoop->thread_name);
return 0;
}
event_loop_handle_pending_channel 函數的作用是遍歷當前 event loop 裏 pending 的 channel event 列表,將它們和 event_dispatcher 關聯起來,從而修改感興趣的事件集合。這裏有一個點值得注意,因爲 event loop 線程得到活動事件之後,會回調事件處理函數,這樣像 onMessage 等應用程序代碼也會在 event loop 線程執行,如果這裏的業務邏輯過於複雜,就會導致 event_loop_handle_pending_channel 執行的時間偏後,從而影響 I/O 的檢測。所以,將 I/O 線程和業務邏輯線程隔離,讓 I/O 線程只負責處理 I/O 交互,讓業務線程處理業務,是一個比較常見的做法。
溫故而知新 !