worldsite / blog.sc

Blogging soul chat, stay cool. via: https://blog.sc
3 stars 0 forks source link

libuv简述 #27

Open suhao opened 3 years ago

suhao commented 3 years ago

libuv简述

Introduction

libuv是一个高性能的事件驱动的I/O库,提供了跨平台的API。libuv是node.js的官方御用库,拥有卓越的系统编程性能,已被更多的如Rust语言等所使用。libuv提供了一个跨平台的抽象,实现不同平台的内核事件通知机制,如FreeBSD-kqueue、Linux-(e)poll、Windows-IOCP。

libuv使用异步的事件驱动编程风格,核心是提供一个event-loop,以及基于I/O和其他事件的通知回调函数。同时提供了一些核心工具,如定时器、非阻塞的网络支持、异步文件系统、子进程等。

异步编程

异步编程在现代编程语言中越来越占主流地位,例如界面编程通过异步来实现非阻塞的交互体验,网络请求的异步处理等。最直观的理解方式就是观察者模式,我们关注每个事件,并对事件做出反应。libuv负责将来自系统的事件收集起来,或者监视其他来源的事件,用户注册相关的事件回调后就可以在事件发生时收到回调。

系统编程中最经常处理的一般是输入和输出,而不是一大堆的数据处理。传统的输入/输出函数(例如read,fprintf)都是阻塞式的,一个read操作返回前程序什么也做不了。例如文件写入数据,网络读取数据等,这些操作耗费的时间相比于cpu的处理时间差得太多。对于高性能的的程序来说,将无法忍受。

一个标准的解决方案是多线程,每一个阻塞的I/O操作都被分配到各个子线程中(或者是使用线程池),当某个线程一旦阻塞,处理器就可以调度处理其他需要cpu资源的线程。

libuv使用异步非阻塞的方案,大多数现代的操作系统都提供了基于事件通知的子系统。例如一个socket的read调用会发生阻塞,但是我们可以请求系统监视socket事件的到来,并将事件放到事件队列中,这样通过程序检查事件来及时获取数据并回调。异步是相对于时间或者空间来讲的,非阻塞的方式下我们无需等待数据瞬间返回而自由的处理我们的其他事情。例如我们使用微信,发给A一个消息后可以看个新闻,A回复消息后会提醒我们,我们再来查看。

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

int main() {
  auto loop = malloc(sizeof(uv_loop_t));
  uv_loop_init(loop);
  uv_run(loop, UV_RUN_DEFAULT);
  uv_loop_close(loop);
  free(loop);
}
  1. 错误处理

同步阻塞式编程,在执行返回后可以返回代表结果的错误码(成功为0我们也统称为错误码的一种)。但是对于异步执行,会在执行失败后,给回调函数传递一个状态参数。libuv的错误码信息被定义为UV_E常量。通常小于0代表出现了错误,但是UV_EOF指示读取到文件等的末端,需要特殊处理。

  1. Handles & Requests

libuv工作在我们监听了相关的特定事件,通常需要创建对应的I/O设备、定时器、进程等handle来实现。handle是不透明的数据结构,通过其uv_type_t的type来指定handle的使用目的。

初始化handle:

uv_TYPE_init(uv_loop_t *, uv_TYPE_t *)
/* Handle types. */
typedef struct uv_loop_s uv_loop_t;
typedef struct uv_handle_s uv_handle_t;
typedef struct uv_stream_s uv_stream_t;
typedef struct uv_tcp_s uv_tcp_t;
typedef struct uv_udp_s uv_udp_t;
typedef struct uv_pipe_s uv_pipe_t;
typedef struct uv_tty_s uv_tty_t;
typedef struct uv_poll_s uv_poll_t;
typedef struct uv_timer_s uv_timer_t;
typedef struct uv_prepare_s uv_prepare_t;
typedef struct uv_check_s uv_check_t;
typedef struct uv_idle_s uv_idle_t;
typedef struct uv_async_s uv_async_t;
typedef struct uv_process_s uv_process_t;
typedef struct uv_fs_event_s uv_fs_event_t;
typedef struct uv_fs_poll_s uv_fs_poll_t;
typedef struct uv_signal_s uv_signal_t;

在异步操作中,handle上有许多与之关联的request,request是短暂性的对象,通常只维持在一个回调函数的时间,对应着handle上的一个IO操作。request用来在初始函数和回调函数直接进行上下文的传递,例如uv_udp_t代表一个udp的socket,对每一个向socket的写入操作,都会新建和回调一个uv_udp_send_t。

/* Request types. */
typedef struct uv_req_s uv_req_t;
typedef struct uv_getaddrinfo_s uv_getaddrinfo_t;
typedef struct uv_getnameinfo_s uv_getnameinfo_t;
typedef struct uv_shutdown_s uv_shutdown_t;
typedef struct uv_write_s uv_write_t;
typedef struct uv_connect_s uv_connect_t;
typedef struct uv_udp_send_s uv_udp_send_t;
typedef struct uv_fs_s uv_fs_t;
typedef struct uv_work_s uv_work_t;

我们来思考下,为何要创建多个request? 是的,同一个request的话我们需要保证request的读写是线程安全的、原子安全的等等等。在使用asio的过程中,我尝试过使用一个request,但是我是将所有数据处理完成后才返回的,并保证了序列性从而保证request数据的安全性。对于libuv这样一个通用性的库,显而易见需要更加灵活的方式来返回数据,所以就需要多个request来保证数据的安全。

  1. IDLING:空转的handle
#include <stdio.h>
#include <uv.h>

int64_t counter = 0;

void wait_for_a_while(uv_idle_t* handle) {
    counter++;

    if (counter >= 10e6)
        uv_idle_stop(handle);
}

int main() {
    uv_idle_t idler;
    uv_idle_init(uv_default_loop(), &idler);
    uv_idle_start(&idler, wait_for_a_while);
    uv_run(uv_default_loop(), UV_RUN_DEFAULT);
    uv_loop_close(uv_default_loop());
}
  1. Storing context

基于回调函数的异步编程,我们需要在调用处和回调函数之间传递一些上下文等特定的信息。所有的handle和request都有一个data域,用来存储信息并传递。uv_loop_t也有一个相似的data域。

传递一个上下文特定信息,这是一个c语言库中很常见的模式。想象一下如何实现C和C++的对象直接的互调操作? 最简单的是把C++的对象当作上下文,在所有的相关操作中附加带上。

文件系统

  1. 异步文件I/O

文件读写可以通过uvfs*函数族和结构体完成,在线程池中调用系统的阻塞函数,在程序交互时通知在事件循环中注册的监视器。如果监视器callback为null,则自动执行阻塞同步。open和close是一次性执行的采用了同步处理,对任务和多路I/O的快速I/O采用异步来提升性能。

由于文件系统和磁盘的调度策略,写入成功的数据并不一定在磁盘上。

关于更多的文件操作API,可以查阅官方文档,这里就不再详细赘述,使用方式都是类似的。

  1. 数据流

讲到文件,就不得不提流。熟悉Asio的同学,可能对其中的流处理比较印象深刻。在libuv中,最基础的I/O操作是流uv_stream_t。TCP套接字、UDP套接字、管道对文件I/O和IPC来讲都可以看作是stream的子类。

数据的离散单元式uv_buffer_t,包含了指向数据的开始地址指针和长度,我们需要管理的式实际数据,需要自己分配和回收内存。类似asio的stream需要我们附加到自己的string上。nodejs使用自己的内存分配Smalloc,将buffer与v8的对象关联起来。

  1. 事件

现代操作系统会提供相关的API来监视文件夹、文件的变化,例如inotify-linux、fsevents-darwin、kqueue-bsd、ReadDirectoryChanges-windows、eventPorts-solaris等。libuv包括了类似的文件监视库:uv_fs_event_start。具体可以参考API文档。

网络

libuv的网络编程接口比BSD的socket便捷很多,因为都是非阻塞的,且原理都是一样的。libuv提供了覆盖了烦人啰嗦的底层任务的抽象函数。

  1. TCP: 面向连接的字节流协议

libuv基于stream实现tcp:

  1. UDP:用户数据报协议,无连接的不可靠网络通信协议

libuv未提供stream形式的实现,而是提供了一个uv_udp_t句柄接收和uv_udp_send_t句柄发送。

  1. DNS

libuv提供了一个异步的DNS解决方案,提供自己的uv_getaddrinfo,在回调函数中可以像使用正常的socket操作一样。

  1. 网络接口信息

uv_interface_addresses可以获得系统的网络接口信息,在服务器准备绑定IP地址时很有用,可以查看哪些端口是否被占用等。

线程

虽然做web编程,了解事件循环的方法即可。但是在nodejs实现前后端的统一后,我们还是有必要了解下处理器完成任务的单元:线程。线程更多是在内部使用,用来在执行系统调用时伪造异步的假象。libuv使用线程使得程序可以异步的执行一个阻塞的任务,使用线程池来保证大量阻塞API的调用。

libuv的线程API与pthread-POSIX API使用方法和语义上近似,在不同的系统平台上由于句法和语义表现都不太相似,libuv支持了有限数量的线程API。

只有一个主线程,主线程只有一个event-loop。不会有其他与主线程交互的线程,除非使用uv_async_send。

  1. mutex

libuv的互斥量与pthread存在一一映射。递归调用互斥量函数在某些系统平台上支持,但是BSD上会报错,例如:

uv_mutex_lock(mut);
uv_thread_create(threadid, entry, (void*)mut);
uv_mutex_lock(mut);

可以用来等待其他线程初始化一些变量然后释放mut锁。

  1. Lock

读写锁是更细粒度的实现机制,两个线程可以同时从共享区中读取数据。以读模式占用读写锁时,无法再以写模式拥有。以写模式占用锁时,其他读写都不可再拥有。

进程

libuv提供了很多的子进程管理函数,跨平台支持stream/pipe完成进程间通信。在unix中的共识是一个进程只做一件事情并做到最好。因此进程通常通过创建子进程来完成不同的任务。一个多进程通过消息通信的模型,比多线程共享内存的模式会比较容易理解很多。

spawning child processes:uv_spawn,进程的命令行参数argv按照惯例,要比实际参数多一个,最后一个设置为NULL。

更多进程相关的内容,请搜索相关的文章来学习,这里只简单提一下。在真正需要时再行查找即可。

事件循环

libuv提供了非常多的控制event-loop的方法,你能通过使用多loop来实现很多有趣的功能。你还可以将libuv的event loop嵌入到其它基于event-loop的库中。比如,想象着一个基于Qt的UI,然后Qt的event-loop是由libuv驱动的,做着加强级的系统任务。

工具类

  1. Timers:libuv的定时器可以设定为定时多次执行
uv_timer_t timer_req;
uv_timer_init(loop, &timer_req);
uv_timer_start(&timer_req, callback, 5000, 2000);

我们可以使用uv_timer_stop(&timer_req)来停止定时器,且在回调函数中可以安全使用。

  1. Idler pattern:空转回调会在每一次的event-loop循环激发一次

可以用于执行一些优先级低的任务,例如可以向开发者发送程序的性能情况,以便于分析。

  1. passing data to worker thread

在使用uv_queue_work的时候,你通常需要给工作线程传递复杂的数据。解决方案是自定义struct,然后使用uv_work_t.data指向它。一个稍微的不同是必须让uv_work_t作为这个自定义struct的成员之一(把这叫做接力棒)。这么做就可以使得,同时回收数据和uv_wortk_t。

  1. External I/O with polling

通常在使用第三方库的时候,需要应对他们自己的IO,还有保持监视他们的socket和内部文件。在此情形下,不可能使用标准的IO流操作,但第三方库仍然能整合进event-loop中。所有这些需要的就是,第三方库就必须允许你访问它的底层文件描述符,并且提供可以处理有用户定义的细微任务的函数。但是一些第三库并不允许你这么做,他们只提供了一个标准的阻塞IO函数,此函数会完成所有的工作并返回。在event-loop的线程直接使用它们是不明智的,而是应该使用libuv的工作线程。当然,这也意味着失去了对第三方库的颗粒化控制。

libuv的uv_poll简单地监视了使用了操作系统的监控机制的文件描述符。从某方面说,libuv实现的所有的IO操作,的背后均有uv_poll的支持。无论操作系统何时监视到文件描述符的改变,libuv都会调用响应的回调函数。

  1. Loading libraries

libuv提供了一个跨平台的API来加载共享库,用来实现插件、扩展、模块系统。

  1. TTY

文字终端长期支持非常标准化的控制序列。它经常被用来增强终端输出的可读性。例如grep --colour。libuv提供了跨平台的,uv_tty_t抽象(stream)和相关的处理ANSI escape codes 的函数。这也就是说,libuv同样在Windows上实现了对等的ANSI codes,并且提供了获取终端信息的函数。

未完待续......