5.线程

等一分钟吗?为什么我们在线程中?难道事件循环不应该是进行网络规模编程的方式吗?嗯…不。线程仍然是处理器执行其工作的媒介。因此,线程有时非常有用,即使您可能不得不费力地处理各种同步原语。

线程在内部用于伪造所有系统调用的异步性质。Libuv还使用线程来允许应用程序异步执行实际阻塞的任务,方法是生成一个线程并在完成时收集结果。

现在主要有两种线程库:Windows线程实现和POSIX的pthreads(7)。libuv的线程API类似于pthreads API,通常具有类似的语义。

libuv的线程功能的一个值得注意的方面是,它是libuv中的一个自包含的部分。虽然其他特性密切依赖于事件循环和回调原理,但线程是完全不可知的,它们在需要时阻塞,通过返回值直接发送错误信号,并且,如第一个示例所示,甚至不需要运行事件循环。

libuv的线程API也非常有限,因为线程的语义和语法在所有平台上都是不同的,具有不同的完整性级别。

本章做了以下假设:只有一个事件循环,在一个线程(主线程)中运行。没有其他线程与事件循环交互(除了使用uv_async_send)。

核心线程操作

这里没有太多内容,你只是使用uv_thread_create()启动一个线程,然后使用uv_thread_join()等待它关闭。thread-create/main.c

 1 int main() {
 2     int tracklen = 10;
 3     uv_thread_t hare_id;
 4     uv_thread_t tortoise_id;
 5     uv_thread_create(&hare_id, hare, &tracklen);
 6     uv_thread_create(&tortoise_id, tortoise, &tracklen);
 7 
 8     uv_thread_join(&hare_id);
 9     uv_thread_join(&tortoise_id);
10     return 0;
11 }
  • 细节:uv_thread_t只是Unix上pthread_t的别名,但这是一个实现细节,避免总是依赖于它为真。

第二个参数是作为线程入口点的函数,最后一个参数是一个void *参数,可以用来向线程传递自定义参数。hare函数现在将运行在一个单独的线程中,由操作系统预先调度:

thread-create/main.c

1 void hare(void *arg) {
2     int tracklen = *((int *) arg);
3     while (tracklen) {
4         tracklen--;
5         sleep(1);
6         fprintf(stderr, "Hare ran another step\n");
7     }
8     fprintf(stderr, "Hare done running!\n");
9 }

pthread_join()允许目标线程使用第二个参数将值传递给调用线程,而uv_thread_join()不这样做。使用线程间通信发送值。

同步基元

这部分是故意简洁的。这本书不是关于线程的,所以我在这里只对libuv api中任何令人惊讶的内容进行分类。至于其他内容,您可以查看pthreads(7)手册页。

互斥体

互斥函数直接映射到pthread的等价物。

libuv mutex functions

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

uv_mutex_init()、uv_mutex_init_recursive()和uv_mutex_trylock()函数成功时会返回0,否则返回错误代码。

如果libuv编译时启用了调试,uv_mutex_destroy(), uv_mutex_lock()和uv_mutex_unlock()将在出错时中止()。类似地,如果错误不是EAGAIN或EBUSY, uv_mutex_trylock()将中止。

支持递归互斥,但不应依赖它们。同样,它们不应该与uv_cond_t变量一起使用。

如果已锁定互斥锁的线程试图再次锁定互斥锁,则默认的BSD互斥锁实现将引发错误。例如,像这样的结构:

uv_mutex_init(a_mutex);
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()时返回错误。

  • 注意:Windows上的互斥锁总是递归的。

读写锁是一种更细粒度的访问机制。两个读者可以同时访问共享内存。当锁被读取器持有时,写入器不能获得锁。当写入器持有锁时,读取器或写入器可能无法获得锁。读写锁在数据库中经常使用。这是一个玩具的例子。

locks/main.c - simple rwlocks

 1 #include <stdio.h>
 2 #include <uv.h>
 3 
 4 uv_barrier_t blocker;
 5 uv_rwlock_t numlock;
 6 int shared_num;
 7 
 8 void reader(void *n)
 9 {
10     int num = *(int *)n;
11     int i;
12     for (i = 0; i < 20; i++) {
13         uv_rwlock_rdlock(&numlock);
14         printf("Reader %d: acquired lock\n", num);
15         printf("Reader %d: shared num = %d\n", num, shared_num);
16         uv_rwlock_rdunlock(&numlock);
17         printf("Reader %d: released lock\n", num);
18     }
19     uv_barrier_wait(&blocker);
20 }
21 
22 void writer(void *n)
23 {
24     int num = *(int *)n;
25     int i;
26     for (i = 0; i < 20; i++) {
27         uv_rwlock_wrlock(&numlock);
28         printf("Writer %d: acquired lock\n", num);
29         shared_num++;
30         printf("Writer %d: incremented shared num = %d\n", num, shared_num);
31         uv_rwlock_wrunlock(&numlock);
32         printf("Writer %d: released lock\n", num);
33     }
34     uv_barrier_wait(&blocker);
35 }
36 
37 int main()
38 {
39     uv_barrier_init(&blocker, 4);
40 
41     shared_num = 0;
42     uv_rwlock_init(&numlock);
43 
44     uv_thread_t threads[3];
45 
46     int thread_nums[] = {1, 2, 1};
47     uv_thread_create(&threads[0], reader, &thread_nums[0]);
48     uv_thread_create(&threads[1], reader, &thread_nums[1]);
49 
50     uv_thread_create(&threads[2], writer, &thread_nums[2]);
51 
52     uv_barrier_wait(&blocker);
53     uv_barrier_destroy(&blocker);
54 
55     uv_rwlock_destroy(&numlock);
56     return 0;
57 }

运行这个程序,观察读者有时是如何重叠的。在有多个编写器的情况下,调度器通常会给它们更高的优先级,因此如果添加两个编写器,您将看到两个编写器都倾向于在读者再次获得机会之前先完成。

在上面的例子中,我们还使用了barrier,这样主线程就可以等待所有的reader和writer表示它们已经结束了。

其他

libuv还支持信号量、条件变量和壁垒,其api与pthread非常相似。

此外,libuv提供了一个方便的函数uv_once()。多个线程可以使用给定的保护和函数指针尝试调用uv_once(),只有第一个会获胜,函数将被调用一次,而且只有一次:

/* 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结构和API。

libuv工作队列

Uv_queue_work()是一个方便的函数,允许应用程序在一个单独的线程中运行一个任务,并有一个回调函数,当任务完成时触发。uv_queue_work()是一个看似简单的函数,但它吸引人的地方在于它允许任何第三方库与事件循环范例一起使用。当您使用事件循环时,必须确保在循环线程中周期性运行的函数在执行I/O时不会阻塞,或者是一个严重的CPU占用者,因为这意味着循环会变慢,事件没有被满负荷处理。

然而,如果你想要响应性(经典的“每个客户端一个线程”服务器模型),很多现有的代码都有阻塞函数的特性(例如一个在外壳下执行I/O的例程)让他们使用事件循环库通常需要在单独的线程中运行自己的任务系统。Libuv只是对此提供了一个方便的抽象。

下面是一个简单的例子,灵感来自node.js是癌症。我们将计算斐波那契数列,在途中稍微睡一会儿,但是在一个单独的线程中运行它,这样阻塞和CPU绑定的任务就不会阻止事件循环执行其他活动。

queue-work/main.c - 懒惰的斐波那契

 1 void fib(uv_work_t *req) {
 2     int n = *(int *) req->data;
 3     if (random() % 2)
 4         sleep(1);
 5     else
 6         sleep(3);
 7     long fib = fib_(n);
 8     fprintf(stderr, "%dth fibonacci is %lu\n", n, fib);
 9 }
10 
11 void after_fib(uv_work_t *req, int status) {
12     fprintf(stderr, "Done calculating %dth fibonacci\n", *(int *) req->data);
13 }

实际的任务函数很简单,没有任何东西表明它将在单独的线程中运行。uv_work_t结构是线索。您可以使用void* data字段通过它传递任意数据,并使用它与线程通信。但是,如果在两个线程都在运行时更改内容,请确保使用了正确的锁。

触发器是uv_queue_work:

queue-work/main.c

 1 int main() {
 2     loop = uv_default_loop();
 3 
 4     int data[FIB_UNTIL];
 5     uv_work_t req[FIB_UNTIL];
 6     int i;
 7     for (i = 0; i < FIB_UNTIL; i++) {
 8         data[i] = i;
 9         req[i].data = (void *) &data[i];
10         uv_queue_work(loop, &req[i], fib, after_fib);
11     }
12 
13     return uv_run(loop, UV_RUN_DEFAULT);
14 }

线程函数将在一个单独的线程中启动,传递uv_work_t结构,一旦函数返回,after函数将在运行事件循环的线程上被调用。它将被传递相同的结构。

对于将包装器写入阻塞库,常见的模式是使用接力棒交换数据。

因为libuv 0.9.4版本有一个额外的函数uv_cancel()可用。这允许您取消libuv工作队列上的任务。只有尚未启动的任务才能被取消。如果一个任务已经开始执行,或者已经完成执行,uv_cancel()将失败。

如果用户请求终止,Uv_cancel()对于清除挂起的任务很有用。例如,一个音乐播放器可以对多个目录进行排队,以扫描音频文件。如果用户终止程序,它应该迅速退出,而不是等到所有挂起的请求都运行之后。

让我们修改fibonacci示例来演示uv_cancel()。我们首先为终止设置一个信号处理程序。

queue-cancel/main.c

 1 int main() {
 2     loop = uv_default_loop();
 3 
 4     int data[FIB_UNTIL];
 5     int i;
 6     for (i = 0; i < FIB_UNTIL; i++) {
 7         data[i] = i;
 8         fib_reqs[i].data = (void *) &data[i];
 9         uv_queue_work(loop, &fib_reqs[i], fib, after_fib);
10     }
11 
12     uv_signal_t sig;
13     uv_signal_init(loop, &sig);
14     uv_signal_start(&sig, signal_handler, SIGINT);
15 
16     return uv_run(loop, UV_RUN_DEFAULT);
17 }

当用户按Ctrl+C触发信号时,我们发送uv_cancel()给所有的工人。Uv_cancel()将返回0给那些已经执行或完成的。

queue-cancel/main.c

1 void signal_handler(uv_signal_t *req, int signum)
2 {
3     printf("Signal received!\n");
4     int i;
5     for (i = 0; i < FIB_UNTIL; i++) {
6         uv_cancel((uv_req_t*) &fib_reqs[i]);
7     }
8     uv_signal_stop(req);
9 }

对于成功取消的任务,调用after函数并将状态设置为uv_ecancel。

queue-cancel/main.c

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

Uv_cancel()也可以用于uv_fs_t和uv_getaddrinfo_t请求。对于文件系统函数族,uv_fs_t。errorno将被设置为UV_ECANCELED。

  • 注意:一个设计良好的程序应该有一种方法来终止已经开始执行的长时间运行的工作者。这样的工作者可以定期检查一个变量,该变量只有主进程设置为终止信号。

线程间通信

有时,您希望各种线程在运行时实际地相互发送消息。例如,您可能在一个单独的线程中运行一些长时间的任务(可能使用uv_queue_work),但希望将进度通知给主线程。这是一个下载管理器通知用户正在运行下载的状态的简单示例。

progress/main.c

 1 uv_loop_t *loop;
 2 uv_async_t async;
 3 
 4 int main() {
 5     loop = uv_default_loop();
 6 
 7     uv_work_t req;
 8     int size = 10240;
 9     req.data = (void*) &size;
10 
11     uv_async_init(loop, &async, print_progress);
12     uv_queue_work(loop, &req, fake_download, after);
13 
14     return uv_run(loop, UV_RUN_DEFAULT);
15 }

异步线程通信工作在循环上,所以尽管任何线程都可以是消息发送者,只有带有libuv循环的线程可以是接收者(或者更确切地说,循环是接收者)。每当Libuv收到消息时,它就会用异步监视器调用回调函数(print_progress)。

  • 警告:重要的是要意识到,因为消息发送是异步的,回调可能会在uv_async_send被另一个线程调用后立即被调用,或者可能会在一段时间后被调用。Libuv还可以组合多个对uv_async_send的调用,并且只调用一次回调。libuv所做的唯一保证是-回调函数在调用uv_async_send之后至少被调用一次。如果你没有对uv_async_send的挂起调用,回调将不会被调用。如果您进行了两次或两次以上的调用,而libuv还没有机会运行回调,那么对于uv_async_send的多次调用,它可能只会调用一次回调。您的回调不会为一个事件被调用两次。

progress/main.c

 1 double percentage;
 2 
 3 void fake_download(uv_work_t *req) {
 4     int size = *((int*) req->data);
 5     int downloaded = 0;
 6     while (downloaded < size) {
 7         percentage = downloaded*100.0/size;
 8         async.data = (void*) &percentage;
 9         uv_async_send(&async);
10 
11         sleep(1);
12         downloaded += (200+random())%1000; // can only download max 1000bytes/sec,
13                                            // but at least a 200;
14     }
15 }

在download函数中,我们修改了进度指示器,并使用uv_async_send将消息排队发送。记住:uv_async_send也是非阻塞的,并且会立即返回。

progress/main.c

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

回调是一个标准的libuv模式,从监视程序中提取数据。

最后,重要的是要记得清理监视器。

progress/main.c

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

在这个显示了滥用数据字段的例子之后,bnoordhuis指出使用数据字段不是线程安全的,uv_async_send()实际上只是为了唤醒事件循环。使用互斥锁或rwlock来确保访问按正确的顺序执行。

  • 注意:互斥锁和rwlocks不能在信号处理程序中工作,而uv_async_send可以。

需要uv_async_send的一个用例是在与需要线程关联的库进行互操作时。例如,在node.js中,一个v8引擎实例、上下文及其对象被绑定到启动v8实例的线程中。与来自其他线程的v8数据结构交互可能会导致未定义的结果。现在考虑一些node.js模块,它绑定了第三方库。它可能是这样的:

  1. 在node中,第三方库被设置为一个JavaScript回调来调用更多信息:
    var lib = require('lib');
    lib.on_progress(function() {
        console.log("Progress");
    });
    
    lib.do();
    
    // do other stuff

     

  2. 自由。Do应该是非阻塞的,但是第三方库是阻塞的,所以绑定使用uv_queue_work。

  3. 在单独的线程中执行的实际工作想要调用进度回调,但不能直接调用v8来与JavaScript交互。它使用uv_async_send。
  4. 在主循环线程(即v8线程)中调用的异步回调,然后与v8交互来调用JavaScript回调。
posted @ 2021-05-11 10:24  风吹大风车  阅读(280)  评论(0)    收藏  举报