libuv筆記 (一)Threads

Threads

線程在現代程序開發中會很常見,當然Libuv也不能缺席這一塊,記得你在使用過程中要非常認真的處理 各種原始的同步問題。

線程會在內部使用,用來在執行系統調用時僞造異步的假象。libuv通過線程還可以使得程序異步地執行一個阻塞的任務。方法就是大量地生成新線程,然後收集線程執行返回的結果。

當下有兩個占主導地位的線程庫:windows下的線程實現和POSIX的pthread。libuv的線程API與pthread的API在使用方法和語義上很接近。

值得注意的是,libuv的線程模塊是自成一體的。比如,其他的功能模塊都需要依賴於event loop和回調的原則,但是線程並不是這樣。它們是不受約束的,會在需要的時候阻塞,通過返回值產生信號錯誤,還有像接下來的這個例子所演示的這樣,不需要在event loop中執行。

因爲線程API在不同的系統平臺上,句法和語義表現得都不太相似,在支持程度上也各不相同。考慮到libuv的跨平臺特性,libuv支持的線程API個數很有限。

最後要強調一句:只有一個主線程,主線程上只有一個event loop。不會有其他與主線程交互的線程了。(除非使用uv_async_send)。

線程主要操作

下面這個例子不會很複雜,你可以使用uv_thread_create()開始一個線程,再使用uv_thread_join()等待其結束。

thread-create/main.c

int main() {
    int tracklen = 10;
    uv_thread_t hare_id;
    uv_thread_t tortoise_id;
    uv_thread_create(&hare_id, hare, &tracklen);
    uv_thread_create(&tortoise_id, tortoise, &tracklen);

    uv_thread_join(&hare_id);
    uv_thread_join(&tortoise_id);
    return 0;
}
TIP

在Unix上uv_thread_t只是pthread_t的別名, 但是這只是一個具體實現,不要過度地依賴它,認爲這永遠是成立的。

uv_thread_create的第二個參數指向了要執行的函數的地址。最後一個參數用來傳遞自定義的參數。最終,函數hare將在新的線程中執行,由操作系統調度。

thread-create/main.c

void hare(void *arg) {
    int tracklen = *((int *) arg);
    while (tracklen) {
        tracklen--;
        sleep(1);
        fprintf(stderr, "Hare ran another step\n");
    }
    fprintf(stderr, "Hare done running!\n");
}

uv_thread_join不像pthread_join那樣,允許線線程通過第二個參數向父線程返回值。想要傳遞值,必須使用線程間通信Inter-thread communication

線程同步

因爲本教程重點不在線程,所以我只羅列了libuv API中一些神奇的地方。剩下的你可以自行閱讀pthreads的手冊。

1. Mutexes

libuv上的互斥量函數與pthread上存在一一映射。如果對pthread上的mutex不是很瞭解可以看這裏

相關函數

UV_EXTERN int uv_mutex_init(uv_mutex_t* handle);
UV_EXTERN void uv_mutex_destroy(uv_mutex_t* handle);
UV_EXTERN void uv_mutex_lock(uv_mutex_t* handle);
UV_EXTERN int uv_mutex_trylock(uv_mutex_t* handle);
UV_EXTERN void uv_mutex_unlock(uv_mutex_t* handle);

uv_mutex_inituv_mutex_trylock在成功執行後,返回0,或者在錯誤時,返回錯誤碼。

如果libuv在編譯的時候開啓了調試模式,uv_mutex_destroy(), uv_mutex_lock()uv_mutex_unlock()會在出錯的地方調用abort()中斷。類似的,uv_mutex_trylock()也同樣會在錯誤發生時中斷,而不是返回EAGAINEBUSY

遞歸地調用互斥量函數在某些系統平臺上是支持的,但是你不能太過度依賴。因爲例如在BSD上遞歸地調用互斥量函數會返回錯誤,比如你準備使用互斥量函數給一個已經上鎖的臨界區再次上鎖的時候,就會出錯。比如,像下面這個例子:

uv_mutex_lock(a_mutex);
uv_thread_create(thread_id, entry, (void *)a_mutex);
uv_mutex_lock(a_mutex);
// more things here

可以用來等待其他線程初始化一些變量然後釋放a_mutex鎖,但是第二次調用uv_mutex_lock(), 在調試模式下會導致程序崩潰,或者是返回錯誤。

注意

在linux中是支持遞歸上鎖的,但是在libuv的API中並未實現。

2. Lock

讀寫鎖是更細粒度的實現機制。兩個讀者線程可以同時從共享區中讀取數據。當讀者以讀模式佔有讀寫鎖時,寫者不能再佔有它。當寫者以寫模式佔有這個鎖時,其他的寫者或者讀者都不能佔有它。讀寫鎖在數據庫操作中非常常見,下面是一個玩具式的例子:

locks/main.c - simple rwlocks

#include <stdio.h>
#include <uv.h>

uv_barrier_t blocker;
uv_rwlock_t numlock;
int shared_num;

void reader(void *n)
{
    int num = *(int *)n;
    int i;
    for (i = 0; i < 20; i++) {
        uv_rwlock_rdlock(&numlock);
        printf("Reader %d: acquired lock\n", num);
        printf("Reader %d: shared num = %d\n", num, shared_num);
        uv_rwlock_rdunlock(&numlock);
        printf("Reader %d: released lock\n", num);
    }
    uv_barrier_wait(&blocker);
}

void writer(void *n)
{
    int num = *(int *)n;
    int i;
    for (i = 0; i < 20; i++) {
        uv_rwlock_wrlock(&numlock);
        printf("Writer %d: acquired lock\n", num);
        shared_num++;
        printf("Writer %d: incremented shared num = %d\n", num, shared_num);
        uv_rwlock_wrunlock(&numlock);
        printf("Writer %d: released lock\n", num);
    }
    uv_barrier_wait(&blocker);
}

int main()
{
    uv_barrier_init(&blocker, 4);

    shared_num = 0;
    uv_rwlock_init(&numlock);

    uv_thread_t threads[3];

    int thread_nums[] = {1, 2, 1};
    uv_thread_create(&threads[0], reader, &thread_nums[0]);
    uv_thread_create(&threads[1], reader, &thread_nums[1]);

    uv_thread_create(&threads[2], writer, &thread_nums[2]);

    uv_barrier_wait(&blocker);
    uv_barrier_destroy(&blocker);

    uv_rwlock_destroy(&numlock);
    return 0;
}

試着來執行一下上面的程序,看讀者有多少次會同步執行。在有多個寫者的時候,調度器會給予他們高優先級。因此,如果你加入兩個讀者,你會看到所有的讀者趨向於在讀者得到加鎖機會前結束。

在上面的例子中,我們也使用了屏障。因此主線程來等待所有的線程都已經結束,最後再將屏障和鎖一塊回收。

3.其他

libuv同樣支持信號量(uv_sem_xx)條件變量(uv_cond_xx)屏障(uv_barrier_xx),而且API的使用方法和pthread中的用法很類似。(如果你對上面的三個名詞還不是很熟,可以看這裏這裏這裏)。

還有,libuv提供了一個簡單易用的函數uv_once()。多個線程調用這個函數,參數可以使用一個uv_once_t和一個指向特定函數的指針,最終只有一個線程能夠執行這個特定函數,並且這個特定函數只會被調用一次

/* Initialize guard */
static uv_once_t once_only = UV_ONCE_INIT;

int i = 0;

void increment() {
   i++;
}

void thread1() {
   /* ... work */
   uv_once(once_only, increment);
}

void thread2() {
   /* ... work */
   uv_once(once_only, increment);
}

int main() {
   /* ... spawn threads */
}

當所有的線程執行完畢時,i == 1

在libuv的v0.11.11版本里,推出了uv_key_t結構和操作線程局部存儲TLSAPI,使用方法同樣和pthread類似。

libuv work queue

uv_queue_work()是一個便利的函數,它使得一個應用程序能夠在不同的線程運行任務,當任務完成後,回調函數將會被觸發。它看起來好像很簡單,但是它真正吸引人的地方在於它能夠使得任何第三方的庫都能以event-loop的方式執行。當使用event-loop的時候,最重要的是不能讓loop線程阻塞,或者是執行高cpu佔用的程序,因爲這樣會使得loop慢下來,loop event的高效特性也不能得到很好地發揮。

然而,很多帶有阻塞的特性的程序(比如最常見的I/O)使用開闢新線程來響應新請求(最經典的‘一個客戶,一個線程‘模型)。使用event-loop可以提供另一種實現的方式。libuv提供了一個很好的抽象,使得你能夠很好地使用它。

下面有一個很好的例子,靈感來自<<nodejs is cancer>>。我們將要執行fibonacci數列,並且睡眠一段時間,但是將阻塞和cpu佔用時間長的任務分配到不同的線程,使得其不會阻塞event loop上的其他任務。

queue-work/main.c - lazy fibonacci

void fib(uv_work_t *req) {
    int n = *(int *) req->data;
    if (random() % 2)
        sleep(1);
    else
        sleep(3);
    long fib = fib_(n);
    fprintf(stderr, "%dth fibonacci is %lu\n", n, fib);
}

void after_fib(uv_work_t *req, int status) {
    fprintf(stderr, "Done calculating %dth fibonacci\n", *(int *) req->data);
}

任務函數很簡單,也還沒有運行在線程之上。uv_work_t是關鍵線索,你可以通過void *data傳遞任何數據,使用它來完成線程之間的溝通任務。但是你要確信,當你在多個線程都在運行的時候改變某個東西的時候,能夠使用適當的鎖。

觸發器是uv_queue_work

queue-work/main.c

int main() {
    loop = uv_default_loop();

    int data[FIB_UNTIL];
    uv_work_t req[FIB_UNTIL];
    int i;
    for (i = 0; i < FIB_UNTIL; i++) {
        data[i] = i;
        req[i].data = (void *) &data[i];
        uv_queue_work(loop, &req[i], fib, after_fib);
    }

    return uv_run(loop, UV_RUN_DEFAULT);
}

線程函數fbi()將會在不同的線程中運行,傳入uv_work_t結構體參數,一旦fib()函數返回,after_fib()會被event loop中的線程調用,然後被傳入同樣的結構體。

爲了封裝阻塞的庫,常見的模式是用baton來交換數據。

從libuv 0.9.4版後,添加了函數uv_cancel()。它可以用來取消工作隊列中的任務。只有還未開始的任務可以被取消,如果任務已經開始執行或者已經執行完畢,uv_cancel()調用會失敗。

當用戶想要終止程序的時候,uv_cancel()可以用來清理任務隊列中的等待執行的任務。例如,一個音樂播放器可以以歌手的名字對歌曲進行排序,如果這個時候用戶想要退出這個程序,uv_cancel()就可以做到快速退出,而不用等待執行完任務隊列後,再退出。

讓我們對上述程序做一些修改,用來演示uv_cancel()的用法。首先讓我們註冊一個處理中斷的函數。

queue-cancel/main.c

int main() {
    loop = uv_default_loop();

    int data[FIB_UNTIL];
    int i;
    for (i = 0; i < FIB_UNTIL; i++) {
        data[i] = i;
        fib_reqs[i].data = (void *) &data[i];
        uv_queue_work(loop, &fib_reqs[i], fib, after_fib);
    }

    uv_signal_t sig;
    uv_signal_init(loop, &sig);
    uv_signal_start(&sig, signal_handler, SIGINT);

    return uv_run(loop, UV_RUN_DEFAULT);
}

當用戶通過Ctrl+C觸發信號時,uv_cancel()回收任務隊列中所有的任務,如果任務已經開始執行或者執行完畢,uv_cancel()返回0。

queue-cancel/main.c

void signal_handler(uv_signal_t *req, int signum)
{
    printf("Signal received!\n");
    int i;
    for (i = 0; i < FIB_UNTIL; i++) {
        uv_cancel((uv_req_t*) &fib_reqs[i]);
    }
    uv_signal_stop(req);
}

對於已經成功取消的任務,他的回調函數的參數status會被設置爲UV_ECANCELED

queue-cancel/main.c

void after_fib(uv_work_t *req, int status) {
    if (status == UV_ECANCELED)
        fprintf(stderr, "Calculation of %d cancelled.\n", *(int *) req->data);
}

uv_cancel()函數同樣可以用在uv_fs_tuv_getaddrinfo_t請求上。對於一系列的文件系統操作函數來說,uv_fs_t.errorno會同樣被設置爲UV_ECANCELED

Tip

一個良好設計的程序,應該能夠終止一個已經開始運行的長耗時任務。
Such a worker could periodically check for a variable that only the main process sets to signal termination.

##Inter-thread communication

很多時候,你希望正在運行的線程之間能夠相互發送消息。例如你在運行一個持續時間長的任務(可能使用uv_queue_work),但是你需要在主線程中監視它的進度情況。下面有一個簡單的例子,演示了一個下載管理程序向用戶展示各個下載線程的進度。

progress/main.c

uv_loop_t *loop;
uv_async_t async;

int main() {
    loop = uv_default_loop();

    uv_work_t req;
    int size = 10240;
    req.data = (void*) &size;

    uv_async_init(loop, &async, print_progress);
    uv_queue_work(loop, &req, fake_download, after);

    return uv_run(loop, UV_RUN_DEFAULT);
}

因爲異步的線程通信是基於event-loop的,所以儘管所有的線程都可以是發送方,但是只有在event-loop上的線程可以是接收方(或者說event-loop是接收方)。在上述的代碼中,當異步監視者接收到信號的時候,libuv會激發回調函數(print_progress)。

WARNING

應該注意: 因爲消息的發送是異步的,當uv_async_send在另外一個線程中被調用後,回調函數可能會立即被調用, 也可能在稍後的某個時刻被調用。libuv也有可能多次調用uv_async_send,但只調用了一次回調函數。唯一可以保證的是: 線程在調用uv_async_send之後回調函數可至少被調用一次。 如果你沒有未調用的uv_async_send, 那麼回調函數也不會被調用。 如果你調用了兩次(以上)的uv_async_send, 而 libuv 暫時還沒有機會運行回調函數, 則libuv可能會在多次調用uv_async_send後只調用一次回調函數,你的回調函數絕對不會在一次事件中被調用兩次(或多次)。

progress/main.c

void fake_download(uv_work_t *req) {
    int size = *((int*) req->data);
    int downloaded = 0;
    double percentage;
    while (downloaded < size) {
        percentage = downloaded*100.0/size;
        async.data = (void*) &percentage;
        uv_async_send(&async);

        sleep(1);
        downloaded += (200+random())%1000; // can only download max 1000bytes/sec,
                                           // but at least a 200;
    }
}

在上述的下載函數中,我們修改了進度顯示器,使用uv_async_send發送進度信息。要記住:uv_async_send同樣是非阻塞的,調用後會立即返回。

progress/main.c

void print_progress(uv_async_t *handle) {
    double percentage = *((double*) handle->data);
    fprintf(stderr, "Downloaded %.2f%%\n", percentage);
}

函數print_progress是標準的libuv模式,從監視器中抽取數據。最後最重要的是把監視器回收。

progress/main.c

void after(uv_work_t *req, int status) {
    fprintf(stderr, "Download complete\n");
    uv_close((uv_handle_t*) &async, NULL);
}

在例子的最後,我們要說下data域的濫用,bnoordhuis指出使用data域可能會存在線程安全問題,uv_async_send()事實上只是喚醒了event-loop。可以使用互斥量或者讀寫鎖來保證執行順序的正確性。

Note

互斥量和讀寫鎖不能在信號處理函數中正確工作,但是uv_async_send可以。

一種需要使用uv_async_send的場景是,當調用需要線程交互的庫時。例如,舉一個在node.js中V8引擎的例子,上下文和對象都是與v8引擎的線程綁定的,從另一個線程中直接向v8請求數據會導致返回不確定的結果。但是,考慮到現在很多nodejs的模塊都是和第三方庫綁定的,可以像下面一樣,解決這個問題:

1.在node中,第三方庫會建立javascript的回調函數,以便回調函數被調用時,能夠返回更多的信息。

var lib = require('lib');
lib.on_progress(function() {
    console.log("Progress");
});

lib.do();

// do other stuff

2.lib.do應該是非阻塞的,但是第三方庫卻是阻塞的,所以需要調用uv_queue_work函數。
3.在另外一個線程中完成任務想要調用progress的回調函數,但是不能直接與v8通信,所以需要uv_async_send函數。
4.在主線程(v8線程)中調用的異步回調函數,會在v8的配合下執行javscript的回調函數。(也就是說,主線程會調用回調函數,並且提供v8解析javascript的功能,以便其完成任務)。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章