今天我们继续高并发的话题,传统的云计算技术,本质上都是基于虚拟机的,云平台可以将一些性能强劲的物理服务器,拆分成若干个虚拟机,提供给用户使用,但在互联网发展到今天,虚拟机还是太重了。即使是飞天集群,新增部署虚拟机的时间也是以分钟来计的。但是对于互联网用户来讲20秒的等等就是就会千万50%以上的用户流失,不能忍受的煎熬,因此Docker秒级启动的速度也不是个完美的解决方案,最终还是要Serverless极速的伸缩才能满足客户需求。
通俗的讲,Serverless就是基建狂魔版的云平台,虽然传统的基建技术安全性更高,稳定性也更好,但是从头修路、盖房、装修成本太高时间也太长,而Serverless本质上是一个比容器还小的最小运行环境的镜像,只要给点阳光就能野灿烂,而且用完以后想拆也很方便,是应对云原生时代最新发展出的神器。
在我之前的博客中也不止一次提到,在Serverless时代,服务冷启动的速度与服务内存的消耗都是决定成败的关键。无GC更不依赖JVM的Rust无论在冷启速度还是在内存消耗上都比JAVA和GO更具优势,而且相比C语言Rust的生产效率也更高,很多储如从函数式语言借鉴而来的Future机制都非常先进,根据官方的测试结果,在性能方面Rust的网络编程框架比JAVA和GO要好得多
但是我意外的看到像Rust中Tokio这样优秀的高并发网络编程框架在中文技术社区却没有个完整的教程,因此笔者决定将这段时间探索Tokio的心得向大家分享一下,
初识Tokio
Tokio是基于Rust开发的异地网络编程框架,用于执行异步代码的多线程运行时。通过Future、async/await等机制,开发者可以让代码产生极高生产力的同时保持程序的性能基本与C语言一致,基于Tokio的开发在编写异步代码时,开发者不能使用Rust标准库提供的阻塞api,而必须使用由Tokio提供,镜像了Rust标准库的API。我们先来看一个Tokio的Helloworld程序
1.首先创建项目
cargo new my-tokio
命令创建一个my-tokio的项目
- 修改Cargo.toml
vi Cargo.toml
在依赖处添加以下内容
[dependencies] tokio = { version = "1", features = ["full"] }
- 修改源代码
vi src/main.rs
并将代码替换为以下内容
async fn say_word() { println!("my tokio"); } #[tokio::main] async fn main() { let op = say_word(); println!("hello"); op.await; }
- 编译并执行
cargo build
cargo run
结果如下:
Finished dev [unoptimized + debuginfo] target(s) in 0.02s Running `target/debug/my-tokio` hello my tokio
这里我们先解释一下async和await的用法,我们看到async fn say_word()中,say_word()函数是被async关键词修饰的,那么也就是说这个函数在被调用时 let op = say_word();
,以上代码是被立即返回而没有被执行的,而这时op实际是一个Future,也就是一个现在为空,在未来才会产生的值(有关Future的机制我们接下来解释),而在调用op.await的时其实是在等到这个async异步操作执行完毕才返回,是一个阻塞操作,因此最终输出会是先打印hello,然后再打印my tokio
程序 程序员如何理解更像自然语言的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)); }
而想象一下如果是传统编程所采用的方式,需要在网络连接完成后调用请求发送的回调函数,然后再请求发送的响应处理方法中再注册接收请求的回调函数,复杂不说还容易出错。
上面的代码就是建立Tcp连接,发送数据,最后读取返回,每个Future都是通过and_then建立关系,而future机制精髓之处在于,整个过程是通过core.run(response).unwrap();这行代码运行起来的,也就是说在Future的帮助下,程序员只需要关心最终的结果就可以了,整个链条通过poll机制串联,从poll机制来看,这几个模块的传递机制如下:
从建立网络连接开始的调用链交给计算机去帮你完成,不但省去了回调所带来的复杂性,最终的效率反而还会更高。
poll模制到底是什么意思?
笔者看到不少博主在介绍Rust的Future等异步编程框架时都提到了Rust的Future采用poll模式,不过到底什么是poll模式却大多语焉不详,其实poll做的本质工作就是监测链条上前续Future的执行状态。
以上述情况为例,poll的方向是由response到request最后是socket,但是state和data的返回方向是完全返过来的,也就是说response通过poll来获取request的state,而request也同样通过poll来获取socket的state。
笔者还是这样的观点,程序员群体之所以觉得future机制难以理解,其关键在于思维模式被计算机的各种回调机制给束缚住了,而忘记了最简单直接的方式。在解决这个问题之前我们先来问一个问题,假如让我们自己设计一个类似于tokio这样的异步Future管理器,应该如何入手?
最直接也是最容易想到的方案就是事件循环,定期遍历整个事件队列,把状态是ready的事件通知给对应的处理程序,这也是我们常说的select方案;另外一种做法是在事件poll管理器中直接拿到处理程序的句柄,不再遍历整个事件队列,而是直接在中断处理响应中把通知发给对应处理进程,比如上述例子中实际是按照poll的链条传递的处理进程句柄的,这就是Poll模式。而基于poll设计的如tokio框架进行应用开发时,程序员根本不必关心整个消息传递,只需要用and_then、spawn等方法建立链条并让系统工作起来就可以了。
而epoll(多路复用)是基于poll的另一种高并发机制,这种机制可以监视多个描述符,一旦某个描述符状态变为就绪,能够通知对应的handler进行后续操作。笔者在前文《这位创造了Github冠军项目的老男人,堪称10倍程序员本尊》中曾经介绍过Tdengine的定时器,其中就有这种多路复用的思想。由于操作系统timer的处理程序还不支持epoll的多路复用,因此每注册一个timer就必须要启动一个线程进行处理,资源浪费严重,因此Tdengine自己实现了一个多路复用的timer,可以做到一个线程同时处理多个timer,这些细节上的精巧设计也是Tdengine封神的原因之一。
后记
写到这突然发现tokio框架的介绍一篇文章根本就不可能完成,那么本文权当一个基础介绍,为入门tokio做准备,如果后面读者们再有强烈需求,我们再继续聊这个话题。