libuv在统一的外部接口之下,实现了Windows和Unix(含Linux、Android等)两个版本。实践中发现两种实现的确存在一定的差异,但这主要是操作系统造成的。可以先选择其中之一进行阅读,读懂之后应该可以很快理解另一种实现的机理,毕竟二者的设计思想是一致的。因为所用的计算机是Windows系统,故先阅读Windows版本。
很明显,libuv的核心逻辑在于uv_loop_t,这里实现了异步事件的调度和管理;而其余诸如uv_udp_t等类型只是对具体I/O接口的封装,真正的调度和控制都要依靠uv_loop_t,这些I/O接口的初始化函数uv_**_init()的第一形参都是uv_loop_t*,正是为了将它们和uv_loop_t建立联系。所以最好的分析方法是从uv_loop_t的生命周期入手。
uv_loop_init():最重要的一句是loop->iocp =CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 1),创建了完成端口句柄。所有加入这个loop的对象都要通过loop->iocp实现异步I/O处理
uv_run():主要内容是一个while循环,调用uv_poll(),下一层调用Windows API GetQueuedCompletionStatusEx()、uv_overlapped_to_req()处理完成端口事件。从函数名称就能看到,就是在这里使用了Windows overlap异步事件处理机制:uv_insert_pending_req()把置位的事件(都封装为uv_req_t指针,在几个队列中保存)插入pending队列末尾。回到uv_run(),是uv_process_reqs调用了loop中的回调函数。调用层次是:
uv_process_reqs():遍历loop->pending_reqs_tail,根据req->Type,switch分类处理各类异步I/O,例如UDP的接收就属于UV_UDP_RECV类型;
uv_process_udp_recv_req:这是UV_UDP_RECV分支的处理;
WSARecvFrom和handle->recv_cb:此处完成异步接收,然后执行回调。
uv_run()还处理关闭句柄等事件。主循环保持运行的必要条件之一是loop->stop_flag == 0,只有uv_stop()才能将其置为1。但是只要loop内部的队列不空,一轮循环就不能确保正常结束。所以关闭loop之前必须关闭loop管理的所有句柄。
uv_run本身是一个长期运行的循环,所以实际使用的时候只能将其封装到工作线程中以免阻塞主线程。2017年3月同样在Windows系统发现,一个loop如果只加载了uv_idle_t对象,执行了uv_run之后,CPU占用率会非常高。这间接的证明了uv_run本身不会阻塞。但为什么在没有uv_idle_t、其他句柄数量非零的情况下能够保持合理的CPU占用率?
答案是:主循环在每一轮都调用uv_backend_timeout返回一个超时量timeout;只要loop还在运行,uv_backend_timeout就调用uv__next_timeout,后者根据loop红黑树管理的uv_timer_t设置一个非零值;如果没有uv_timer_t,就把超时时间设置为无限。有了非零的超时等待时间,主循环通过uv_pool_exd调用GetQueuedCompletionStatusEx,就会在没有事件的情况下等待timeout时长(单位:ms),不至于造成主循环不间断的执行,导致占满了整个CPU处理核心。
主循环还有下列两个调用,因为是复杂的宏定义,目前不清楚其真实含义:
uv_check_invoke(loop);
uv_process_endgames(loop);
uv_udp_init()创建了SOCKET,先通过宏uv__handle_init把udp的wq加入loop->wq的队尾;然后调用uv_udp_set_socket()函数完成如下工作:
a) 先把套接字设置为异步;
b) 接下来调用Windows API CreateIoCompletionPort,将套接字句柄和loop->iocp关联起来,以后套接字的I/O事件都会触发loop->iocp的完成端口事件。
c) 调用uv_req_init将内部的uv_req_t对象初始化,类型为UV_UDP_RECV,data指向uv_udp_t。
前文提到了udp收的回调处理过程,发送却有所不同。异步接收是被动过程,而异步发送仍然是一个主动过程。调用关系是:uv_udp_send->uv__udp_send->uv__send->WSASendTo。其中uv__send将回调关联到uv_udp_send第一形参uv_udp_send_t* req,接着根据WSASendTo的返回值分情况调用uv_insert_pending_req把req插入loop的pending队列。所以,uv_udp_send()的回调由uv_process_reqs处理UV_POLL_REQ,也就是说,响应回调的并不是uv_udp_t对象。
uv_run()->uv_process_timers(),这里用红黑树对所有的timer按其due数值排序,接着在每一轮循环内依次调用uv_timer_stop()、uv_timer_again()、timer->timer_cb((uv_timer_t*)timer),重启所有达到时间周期的定时器并执行定时器的回调。这说明,定时器的计时和回调都是loop控制的。
timer的due分量是怎么来的?继续剖析代码找到了答案:uv_timer_again()仍然在内部先后调用uv_timer_stop()、uv_timer_start(),后者又调用了get_clamped_due_time给due赋值。get_clamped_due_time最关键的一句是把loop_time +timeout作为返回值,最终赋值给timer->due。可见,uv_timer_start()根据loop->time计数,确定了timer将在loop计时到多少之后触发回调。再往回推,得知uv_run()主循环第一句调用uv_update_time()至少对定时器是有意义的:不断更新loop->time计数,决定各定时器下次触发的时间。
Windows版本的代码显示,虽然用到了高精度计时,但是loop->time只保证ms级精度,那么uv_timer_t的精度也只能达到这个水平。
libuv的tree.h封装了红黑树,有人包含这个头文件专门使用红黑树,据说性能还很好。众多的定时器被loop管理在红黑树中,根据各自的due值排序,决定哪些在本轮循环中需要执行回调。
uv_async_t能沟通uv_run所在的线程和用户线程,只有这种类型才能保障线程安全。
uv_async_init():内部给回调指针赋值之后,这一句很关键:uv_req_init。原来,在uv_async_t内部包含了一个uv_req_t,它的类型是UV_WAKEUP(字面含义是唤醒loop去做调用者分配的事情),data指向uv_async_t,这个和uv_udp_t初始化的情况很相似。
uv_async_send():先后调用了uv__atomic_exchange_set、宏POST_COMPLETION_FOR_REQ。后者展开是调用PostQueuedCompletionStatus,这样会令下次uv_run->uv_poll_ex调用的GetQueuedCompletionStatus得到置位的事件。这就是为什么调用者能在loop线程之外安全的向loop线程传递信息(包括发送数据、给loop增减uv_**_t句柄——只要处理好时序就没问题)的原因。
一旦有事件置位,会形成如下调用链:
uv_run->uv_process_reqs->uv_process_async_wakeup_req执行回调。
前文提到uv_udp_t、uv_async_t都内置了uv_req_init对象。这些对象能在I/O事件到来或用户调用uv_async_send()的情况下令loop->iocp在GetQueuedCompletionStatus调用中获得置位的事件并进行相应的处理。
所有uv_req_init对象都和uv_loop_t::iocp配合使用,在uv_run->uv_process_reqs中处理置位的事件。
uv_run的主循环调用了uv_idle_invoke(loop),这个函数是宏UV_LOOP_WATCHER_DEFINE展开的一部分,即void uv_##name##_invoke(uv_loop_t* loop)部分。libuv类似的宏有些可读性良好,如有需要可以看看部分展开的内容,不必全部阅读。
这部分宏函数的主要执行体是一个while循环,遍历loop的idle队列,依次执行回调函数。很明显,假设这个loop除了uv_idle_t之外没有其他句柄,实际上loop的主循环也就退化成了uv_##name##_invoke的while循环,这个循环没有任何等待和延时,必然占满uv_run线程所在的CPU处理单元。
这个函数其实是对close各种句柄的集成。内部实现仍然是按照被关闭句柄的类型,执行对于的close函数。例如uv_udp_close、uv_async_close等等。
从代码中看到,几乎所有类型的句柄在close过程中,都会调用uv_want_endgame、uv__handle_closing,尽管二者的顺序并不固定。前者是把句柄指针加入loop的endgame队列,最终会被uv_run的主循环所执行;后者是一个宏,主要功能是修改此句柄的标志位,包括取消active标志、设置close标志。
这两句的修改,会在uv_run的下一轮主循环得到处理。
windows完成端口可同时等待多个句柄,但windows内核限定了句柄数量是64个以内。如果给一个loop添加超过64个句柄(不包括定时器,因为这类句柄没有使用完成端口,但UDP等类型属于overlapped可以监控的句柄)不知道,会不会发生错误。