藏川线前段

--- 摄于 2017 年 9 月 藏川线前段

异步(二)

上一话说到资源限制问题,为了尽可能压榨机器的性能,出现了以异步为代表的一系列并发模型,但其实,我们需要往底层看看,异步是现在的共识产物,在早前,还有更多的东西需要了解。

硬件层面

最原始的计算机模型是处理器 + 输入设备 + 输出设备,后来由于各种各样的原因,为了加快速度,处理器和输入输出设备之间加了一层又一层的中间层,将大部分控制权下放到了设备内部(DMAC),处理器只是在设备调用硬件终端的时候,去响应该设备预先注册在 OS 中的处理逻辑,这里就包括读和写操作。这样的操作,大大解放了处理器能力,让设备在准备数据的时候,处理器还可以去做其他的计算任务。

软件层面

除了硬件层面,在软件层面,也是一样的操作,OS 提供的进程/线程概念很好,但不够好,所有的调度都由 OS 提供的话,无法在想要的地方做到极致优化。OS 的调度单元是线程,由时间片 + 优先级等策略进行调度,尽可能让每个线程都能有自己的执行时间片,让所有任务都能公平执行下去。但在用户层,有些任务可以有自己的线程内优先级,有些任务会阻塞线程,用户希望将这部分阻塞的东西切走,然后继续利用该线程的可用时间片去做另外的事情,让线程的利用率得到最大化,而不是线程刚切过来,就被一个阻塞任务给调度走了。

这样的线程内调度功能,就是所谓的绿色线程:由用户态实现的在线程中的线程,这是一层由用户态实现的运行时,拥有自己的调度策略,大体上可以分为两种:

顾名思义,所谓的抢占式,就是在 runtime 内部实现类似于信号量的东西,给每个抽象出来的 task 固定的执行时间,到时间之后,通过信号量通知正在运行的 task 转换自己的状态为 pending,保存好自己上下文状态(寄存器值),然后切换给下一个 task 执行。

所谓的协作式,就是没有抢占式内部的这一套强制让出机制,由用户代码自行写入让出点(yield point),如果没有让出点,runtime 无法强制切换正在运行的 task,必须等它执行完成才行。

这两种方式各有优缺点,从理论上来说,使用 runtime 的开发者们如果能够遵守规范,在该让出的时候主动将自己的执行权让出,协作式的运行时效率会是最好的,但是,现实是总会有一些奇怪的行为导致协作式的假设被 break,出现各种意外情况,无法确定哪种更好。

Syscall

光有 runtime 也不行,除了重计算任务之外,大部分阻塞线程执行的任务实际上是因为调用了阻塞型的 syscall,线程自动进入等待状态,需要等 io 数据加载完成之后,OS 才会将该线程唤醒,然后继续执行任务。然而,绿色线程的实现前提是,OS 线程不能被调度到等待状态,或者说,需要将导致 OS 线程等待的 syscall 给全替换掉,让它用另外一种方式去通知 io 数据加载完成。

这时候,我们就要真正引入 io 异步的基础:异步化 syscall。从网络这边来看,常见的三大平台,都有自己对应的实现:

它们都提供了一种异步通知的能力,将事件注册进入 OS 之后,不需要在原地阻塞,可以继续执行其他任务,等 OS 完成后,通过另一个 syscall 可以统一将多个完成任务的 id 通知上来,实现了 IO 事件的异步化。

但它们的行为又是不同的:

完成式和就绪式的最大区别,在我看来就是,注册给 OS 的时候,是否提供一个可用的 buffer:

基于完成式,其实最大的好处是,不用再从 OS copy 一遍数据到用户层,但是坏处是,需要提前申请内存,如果 IO 操作过多,可能会有内存预分配过多的风险,风险在于被人基于内存进行攻击。

我以 epoll 为例,写一个简单的示意代码:

fn main() {
    let queue = unsafe { libc::epoll_create(1) };
    
    if queue < 0 {
        panic!("{}", std::io::Error::last_os_error());
    }

    let mut stream = TcpStream::connect(..);
    stream.set_nonblocking(true).unwrap();
	
    let mut event = libc::epoll_event {
		events: (libc::EPOLLIN | libc::EPOLLOUT | libc::EPOLLONESHOT) as u32,
		epoll_data: 1,
	};
    
    let res = unsafe {
        libc::epoll_ctl(queue, libc::EPOLL_CTL_ADD, stream.as_raw_fd)
    };
	if res < 0 {
        panic!("{}", std::io::Error::last_os_error());
    }
    let mut events = Vec::with_capacity(10);
    // -1 means block on until events happened, no timout interrupts
    // epoll_wait will block on here, with all registration fds,
    // It can be executed using a separate thread.
    let res = unsafe { libc::epoll_wait(queue, events.as_mut_ptr(), 10, -1) };

    if res < 0 {
    	panic!("{}", std::io::Error::last_os_error());
    }
    unsafe { events.set_len(res as usize) };
                            
   for event in events {
        if event.events == libc::EPOLLOUT as u32 {
             // can write
        } else if event.events == libc::EPOLLIN as u32 {
            // can read
        } else if event.events == libc::EPOLLIN | libc::EPOLLOUT as u32 {
           // both read and write
        }
  }
    	
  unsafe { libc::close(queue) };
}

epoll_wait 可以支持 timeout 唤醒,这意味着,上一话说的时间定时可以使用 epoll_wait 来实现,这样将 IO 事件和定时任务结合在一个线程去等待,其他线程就可以一直不阻塞地执行了。但是,在 io_uring 未能大规模使用之前,epoll 只支持网络相关的异步化操作,与文件相关的操作无法支持。

最后

到这,搞定了一些前置的东西,用户层的异步化无法脱离这些 OS API 支持,而 OS 的异步化是硬件交互天生如此,在最初接触异步的时候,无论哪个科普文章都无法让我满意的原因之一就是:我无法理解到底是哪个外力通知我在操作完成时,去接着做事情,这只能一步步往下找,最后找到 OS 层,找到硬件中断通知的机制,才能理解任务中断是如何被唤醒的。

下一篇,回归到 Rust 语言,Future 的设计。

评论区

加载更多

登录后评论