专栏首页Rust学习专栏Rust的Future、GO的Goroutine、Linux的Epoll高并发背后的殊途同归
原创

Rust的Future、GO的Goroutine、Linux的Epoll高并发背后的殊途同归

​今天我们继续高并发的话题,在上次的博客中我们有提到,Rust的Future机制非常有助于程序员按照更为自然、简洁的逻辑去设计系统,我们必须要知道高并发系统的关键在于立交桥的分流与导流构造而非信号灯的限流。因此把精力放在设计锁、互斥系这些信号系统上是非常事倍功半的。

从机制上来讲Rust从函数式语言借鉴而来的Future机制是先进的,而且从亲身教小孩编程的时候笔者意外发现,对于没有任何编程经验的人来说,他们学习async/await的成本,要比理解层层回调的机制要低得多。程序员在学习Future的难度大,其实完全是因为之前的历史包袱太重了。

为什么说Future更像自然语言

在以下这段代码中,网络连接socket、请求发送request、响应接收response三个对象全部都是future类型的,也就是在代码执行之后不会被执行也没有值仅有占位的意义,当未来执行后才会有值返回,and_then方法其实是在future对象执行成功后才会被调用的方法,比如read_to_end这行代码就是在request对象执行成功后,调用read_to_end方法对读取结果。

use futures::Future;
use tokio_core::reactor::Core;
use tokio_core::net::TcpStream;
fn main() {
    let mut core = Core::new().unwrap();
     let addr = "127.0.0.1:8080".to_socket_addrs().unwrap().next().unwrap();
     let socket = TcpStream::connect(&addr, &core.handle());

     let request = socket.and_then(|socket|{
         tokio_core::io::write_all(socket, "Hello World".as_bytes())
     });
     let response = request.and_then(|(socket, _)| {
         tokio_core::io::read_to_end(socket, Vec::new())
     });
 
     let (_, data) = core.run(response).unwrap();
     println!("{}", String::from_utf8_lossy(&data));
 }
 

而想象一下如果是传统编程所采用的方式,需要在网络连接完成后调用请求发送的回调函数,然后再请求发送的响应处理方法中再注册接收请求的回调函数,复杂不说还容易出错。

而future机制精髓之处在于,整个过程是通过core.run(response).unwrap();这行代码运行起来的,也就是说开发人员只需要关心最终的结果就可以了。从建立网络连接开始的调用链交给计算机去帮你完成,最终的效率反而还会更高。

并发中的poll模式到底是什么意思?

笔者看到不少博主在介绍Rust的Future等异步编程框架时都提到了Rust的Future采用poll模式,不过到底什么是poll模式却大多语焉不详。

笔者还是这样的观点,程序员群体之所以觉得future机制难以理解,其关键在于思维模式被计算机的各种回调机制给束缚住了,而忘记了最简单直接的方式。在解决这个问题之前我们先来问一个问题,假如让我们自己设计一个类似于goroutine之类事件高度管理器,应该如何入手?

最直接也是最容易想到的方案就是事件循环,定期遍历整个事件队列,把状态是ready的事件通知给对应的处理程序,这也是之前mfc和linux的select的方案,这实际上也就是select方案;另外一种做法是在事件中断处理程序中直接拿到处理程序的句柄,不再遍历整个事件队列,而是直接在中断处理响应中把通知发给对应处理进程,这就是Poll模式。

多路复用是另一种机制,这种机制可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。笔者在前文《这位创造了Github冠军项目的老男人,堪称10倍程序员本尊》中曾经介绍过Tdengine的定时器,其中就有这种多路复用的思想。由于操作系统timer的处理程序还不支持epoll的多路复用,因此每注册一个timer就必须要启动一个线程进行处理,资源浪费严重,因此Tdengine自己实现了一个多路复用的timer,可以做到一个线程同时处理多个timer,这些细节上的精巧设计也是Tdengine封神的原因之一。

Epoll的代价-少量连接场景不适用

当然epoll还有一个性能提升的关键点,那就是使用红黑树做为事件队列的存储模型,我们在上文《用了十年竟然都不对,Java、Rust、Go主流编程语言的哈希表比较》中曾经提到过,红黑树是一种解决哈希碰撞时比较好的退化选择,不过这也给epoll机制带来了一些适用场景的限制,如果连接总数本身就不高的情况下,那么epoll可能还不如select高效。其原因同时也在《用了十年竟然都不对,Java、Rust、Go主流编程语言的哈希表比较》中说明了,由于红黑树在内存中也是散列的状态,这就会造成连续存储的数据在总长度较小的情况下获得比红黑树更好的性能,具体这里就不加赘述了。

ET还是LT如何触发又是个选择

Epoll的触发又分为水平触发和垂直触发两种模式,具体介绍如下:

LT(level triggered)水平触发,是缺省的工作方式,顾名思义,也就是即使状态不变也可能模式通知的模式,同时支持阻塞和非阻塞两种方案.在这种做法中,内核通知注册的进程一个有任务已经就绪,不过这种模式下就算进程不作任何操作,内核还是会继续通知,所以这种模式属于唐僧式的模式,虽然唠叨但出BUG的可能性要小一点。

ET (edge-triggered),垂直触发,也就是当且仅当有任务状态发生变化时才会被触发,属于高速工作方式。在ET模式下仅当有事件从未就绪变为就绪时,内核才会触发通知。但是内核的通知只会发出一次,也就是说如果事件一直没有进程处理,内核也不会发送第二次通知。

其实从代码来看ET和LT的差别不多,具体如下:

if (epi->event.events & EPOLLONESHOT)              
    epi->event.events &= EP_PRIVATE_BITS;          
else if (!(epi->event.events & EPOLLET)) { //如果是是LT模式,当前事件会被重新放到epoll的就绪队列。              
list_add_tail(&epi->rdllink, &ep->rdllist);         
ep_pm_stay_awake(epi);
}可以看到LT模式从不会丢弃事件,只要队列里还有数据能够读到,就会不断的发起通知,属于链式反应的一种,效率低点但不容易出错,而ET只在则只在新事件到来时才会发起通知,效率高但也容易出BUG。当然如果socketfd事件与处理线程之间是一对多的关系,也就是说一个socketfd只对应一个线程,那倒也还好说。但由于在很多高并发的场景下,很多socketfd是由多个进程同时监控的,因此这又会造成一个惊群的问题。

正如前文所说,多路复用机制也允许多个进程(线程)在等待同一个事件的到来,当这个 fd(socket)的事件发生的时候,这些睡眠的进程(线程)就会被同时唤醒,去处理这个事件,这和一大群鱼,争抢一个鱼食的现象非常类似,因此也就被称为"惊群"现象。

由于大量的进程计算资源被浪费在被抢食的过程中,实际上却没做任何有意义的工作,因此"惊群"效率低下,而且在鱼群抢食的过程中,会造成系统短暂的吞吐能力下降。对于流量分布极不均衡的系统来说,惊群的影响很大。

不过在LT模式下,通知是链式的,因此惊群难以避免,ET模式下效率虽多,但如果有一个进程出现问题,则很有可能造成难以察觉的BUG,高并发系统绝对是个说起来容易,做起来难的设计。

原创声明,本文系作者授权云+社区发表,未经许可,不得转载。

如有侵权,请联系 yunjia_community@tencent.com 删除。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • GO、Rust这些新一代高并发编程语言为何都极其讨厌共享内存?

    今天我想再来讨论一下高并发的问题,我们看到最近以Rust、Go为代表的云原生、Serverless时代的语言,在设计高并发编程模式时往往都会首推管道机制,传统意...

    beyondma
  • 浅谈Rust和Golang协程设计

    根据维基百科的定义,协程,是指在非抢占式地处理多任务场景下,用于生成子程序的计算机程序组件,它允许在执行过程中被暂停或恢复。

    云云众生
  • 深入Go语言网络库的基础实现

    Go语言的出现,让我见到了一门语言把网络编程这件事情给做“正确”了,当然,除了Go语言以外,还有很多语言也把这件事情做”正确”了。我一直坚持着这样的理念——要做...

    李海彬
  • 深入Go语言网络库的基础实现

    Go语言的出现,让我见到了一门语言把网络编程这件事情给做“正确”了,当然,除了Go语言以外,还有很多语言也把这件事情做”正确”了。我一直坚持着这样的理念——要做...

    李海彬
  • 百万 Go TCP 连接的思考2: 百万连接的吞吐率和延迟

    上一篇epoll方式减少资源占用 介绍了测试环境以及epoll方式实现百万连接的TCP服务器。这篇文章介绍百万连接服务器的几种实现方式,以及它们的吞吐率和延迟。

    李海彬
  • 从网络库浅析GO协程切换

    GO原生支持协程,并且服务器上可以支持上万的协程goroutine。所以在网络编程方面,一般都采用一个连接开启一个协程的模式。

    1011689
  • Go语言 Go的网络轮询及IO机制

    简介 这篇介绍了Go的运行时系统——网络I/O部分。 阻塞 Go语言中,所有的I/O都是阻塞的,因此我们在写Go系统的时候要秉持一个思想:不要写阻塞的inter...

    李海彬
  • Go语言 Go的网络轮询及IO机制

    简介 这篇介绍了Go的运行时系统——网络I/O部分。 阻塞 Go语言中,所有的I/O都是阻塞的,因此我们在写Go系统的时候要秉持一个思想:不要写阻塞的inter...

    李海彬
  • 一顿操作猛如虎,一看结果还是 0,Rust 能避免 Go 的 Bug?

    早些时候我看到这样一条新闻,在谈到Linux内核与Rust的关系时,谷歌曾表示Rust现在已经准备好加入C语言,成为实现内核的实用语言。它可以帮助减少特权代码中...

    MikeLoveRust
  • 零成本异步 I/O (上)

    async 是一个修饰符,它可以应用在函数上,这种函数不会在调用时一句句运行完成,而是立即返回一个 Future 对象,这个 Future 对象最终将给出这个函...

    MikeLoveRust
  • 详解Go语言I/O多路复用netpoller模型

    从 Go 源码目录结构和对应代码文件了解到 Go 在不同平台下的网络 I/O 模式的有不同实现。比如,在 Linux 系统下基于 epoll,freeBSD 系...

    luozhiyun
  • 那些必须要了解的Serverless时代的并发神器-Rust语言Tokio框架基础

    今天我们继续高并发的话题,传统的云计算技术,本质上都是基于虚拟机的,云平台可以将一些性能强劲的物理服务器,拆分成若干个虚拟机,提供给用户使用,但在互联网发展到今...

    beyondma
  • Go 语言网络轮询器的实现原理

    在今天,大部分的服务都是 I/O 密集型的,应用程序会花费大量时间等待 I/O 操作执行完成。网络轮询器就是 Go 语言运行时用来处理 I/O 操作的关键组件,...

    范蠡
  • 百万 Go TCP 连接的思考: epoll方式减少资源占用

    前几天 Eran Yanay 在 Gophercon Israel 分享了一个讲座:Going Infinite, handling 1M websockets...

    李海彬
  • Rust异步浅谈

      这篇文章主要描述了Rust中异步的原理,Rust异步也是在最近的版本中(1.39)中才稳定下来。希望可以通过这边文章在提高自己认知的情况下,也可以给读者带来...

    MikeLoveRust
  • 【翻译】withoutboats 的 io-uring 笔记

    去年秋天,我正在开发一个库,创建一套安全的API,实现在一个 io-uring 实例的基础上执行 future。虽然最后发布了一个叫 iou 的 liburin...

    MikeLoveRust
  • 【实践】Golang的goroutine和通道的8种姿势

    如果说php是最好的语言,那么golang就是最并发的语言。 支持golang的并发很重要的一个是goroutine的实现,那么本文将重点围绕goroutin...

    辉哥
  • Rust异步浅谈(转)

    这篇文章主要描述了Rust中异步的原理与相关的实现,Rust异步也是在最近的版本(1.39)中才稳定下来。希望可以通过这边文章在提高自己认知的情况下,也可以给读...

    8菠萝
  • Go语言初窥

    Go与C/C++消耗的CPU差距不大,但由于Go是垃圾回收型语言,耗费的内存会多一些。 拿Go与同为垃圾回收型语言的Java简单比较一下。

    sparkle123

扫码关注云+社区

领取腾讯云代金券