Tokio第二天 Task与同步

1,889 阅读11分钟

Rust中的线程模型是1:1的,也就是说对应于系统线程.也正是因为这样,我们在进行线程上下文切换的时候,完全由操作系统负责调度从而需要较大的资源消耗.为了减少系统调用的消耗,一个好的办法就是想办法将调度转移到用户态中由程序来负责调度,goroutine的实现就是这个思想.同样的,在Tokio中的任务task也是由框架本身的调度器来负责,上下文切换的开销很小.

这里有一点需要注意的,并不是所有的任务都是task.上一篇讲过运行时中也分工作线程和阻塞线程,只有异步任务才能称为task,对于blocking thread中的同步任务调度依然是需要系统调用.

1. Task的常用函数

1.1 spawn

spawn()函数作用很简单,就是在当前runtime中创建一个异步任务

use chrono::Local;
use std::thread;
use tokio::{self, task, runtime::Runtime, time};
​
fn now() -> String {
    Local::now().format("%F %T").to_string()
}
​
fn main() {
    let rt = Runtime::new().unwrap();
    let _guard = rt.enter();
    task::spawn(async {
        time::sleep(time::Duration::from_secs(1)).await;
        println!("task over: {}", now());
    });
    drop(_guard);
    println!("main thread print");
    thread::sleep(time::Duration::from_secs(2));
}

image-20230730082448871

这里我们利用spawn()创建了一个异步任务,在等待1s后进行打印.

1.2 spawn_blocking

spawn_blocking()从名字不难看出它肯定和同步任务有关,主要功能就是创建一个blocking thread去执行同步任务.

fn main() {
    let rt = Runtime::new().unwrap();
    let x=3;
    let _guard = rt.enter();
    task::spawn(async {
        time::sleep(time::Duration::from_secs(1)).await;
        println!("task over: {}", now());
    });
​
    let task2=task::spawn_blocking(move||{x*x});
​
    drop(_guard);
    println!("main thread print");
    thread::sleep(time::Duration::from_secs(2));
    rt.block_on(async{
        let result=task2.await.unwrap();
        println!("result={}",result);
    })
}

image-20230730083812605

稍微更改一下上面的demo,这次添加了一个计算任务task2并且放入blocking thread中单独执行.

1.3 block_in_place

在异步任务中同样会存在某些任务需要长时间运行甚至阻塞线程,这个时候就会造成其他的异步任务一直处于饥饿中.block_in_place()就可以很好的解决这类问题,它可以将工作线程中的异步任务转移到其他工作线程去执行,然后将此工作线程转为异步阻塞线程,实现了异步上下文阻塞避免了额外的上下文切换.

fn main() {
    let rt = Runtime::new().unwrap();
    let x=3;
    let _guard = rt.enter();
    task::spawn(async {
        time::sleep(time::Duration::from_secs(1)).await;
        println!("task over: {}", now());
        task::block_in_place(||{
            println!("block_in_place,thread name={}",thread::current().name().unwrap());
        });
    });
    let task2=task::spawn_blocking(move||{x*x});
    drop(_guard);
​
    println!("main thread print");
    thread::sleep(time::Duration::from_secs(2));
    rt.block_on(async{
        let result=task2.await.unwrap();
        println!("result={} thread name={}",result,thread::current().name().unwrap());
    })
}

image-20230730092051612

1.4 yield_now

yield_now()能让当前正在执行的异步任务立即放弃CPU,进入就绪队列等待下次轮询调度.这里yield_now()本身也是一个让出CPU的异步任务,所以也需要await进行异步调用.

1.5 abort

abort可以取消一个正在执行的异步任务

fn main(){
    let rt=Runtime::new().unwrap();
​
    rt.block_on(async{
        let task=task::spawn(
            async{
                sleep(Duration::from_secs(2)).await;
                println!("task finish");
            }
        );
        sleep(Duration::from_secs(1)).await;
        task.abort();
        let abort_err=task.await.unwrap_err();
        println!("{}",abort_err.is_cancelled());
    })
}

image-20230730094424713

1.6 LocalSet与spawn_local

前面提到Tokio的调度系统可以调度工作线程中的所有异步任务,当某些工作线程空闲时会被调度执行其他工作线程队列中等待执行的异步任务,这个时候就出现了跨线程执行的情况.如果异步任务并没有实现线程安全,那么在调度系统的调度下一旦转移到其他线程执行就会导致错误.因此在这种情况下,我们需要能够确保任务仅仅在指定工作线程执行的功能,这就是LocalSet.LocalSet能够保证异步任务被放在独立的本地任务队列而不会被其他线程执行,,而向这个本地任务队列添加异步任务的操作就是spawn_local.

fn main() {
    let rt = Runtime::new().unwrap();
    let local_tasks = tokio::task::LocalSet::new();
​
    // 向本地任务队列中添加新的异步任务,但现在不会执行
    local_tasks.spawn_local(async {
        println!("local task1,{:?}",thread::current().id());
        time::sleep(time::Duration::from_secs(1)).await;
        println!("local task1 done");
    });
​
    local_tasks.spawn_local(async {
        println!("local task2,{:?}",thread::current().id());
        time::sleep(time::Duration::from_secs(2)).await;
        println!("local task2 done");
    });
​
    println!("before local tasks running: {}", now());
    rt.block_on(async{
        local_tasks.await;
    });
}

image-20230730104324020

1.7 select!

看到这个宏第一反应想到的应该是Go的select关键字,用来随机选择接收不同通道的返回值.Tokio中的select!宏作用则是随机轮询所有分支,当某个分支的异步任务完成时,执行对应的操作并且取消其他分支的异步任务,我们也可以通过加上biased使每次轮询是按照书写顺序进行.

2. Task的通信与同步

在多线程中我们需要channel在线程间传递消息或者利用Mutex等锁去同步,这些在Tokio中同样有着对应实现.

2.1 Tokio的通道

Tokio提供了多种功能的通道

  • oneshot: 一对一的通道且容量为1,即该通道只能有一个发送者和一个接收者且每次最多只能发送一个数据.
  • mpsc: 多对一的通道,和Rust的mpsc相似可以有多个发送者,但仅能有一个接收者.
  • broadcast: 多对多通道,可以同时有多个发送者发送和多个接收者接收.
  • watch: 一对多通道,只能有一个发送者但可以有多个接收者.

2.1.1 oneshot

oneshot可以称得上一次性通道,并且send()发送数据是不阻塞的.能顺利发送就发送,不能的话就直接返回错误,并不会等待接收者读取.对于接收者,本身就被封装为异步任务因此只需要await就可以接收数据.我们可以用match去匹配接收返回值,也可以用select!轮询得到结果.

fn main() {
    let rt = Runtime::new().unwrap();
    rt.block_on(async {
        let (tx, rx) = sync::oneshot::channel();
​
        tokio::spawn(async move {
            if tx.send(33).is_err() {
                println!("receiver dropped");
            }
        });
​
        match rx.await {
            Ok(value) => println!("received: {:?}", value),
            Err(_) => println!("sender dropped"),
        };
    });
}

image-20230730181738463

#[tokio::main]
async fn main() {
    let mut interval = tokio::time::interval(tokio::time::Duration::from_millis(100));
​
    let (tx, mut rx) = sync::oneshot::channel();
​
    task::spawn(async move {
        sleep(Duration::from_secs(1)).await;
        tx.send("aaa").unwrap();
    });
    loop {
        // 注意,select!中无需await,因为select!会自动轮询推进每一个分支的任务进度
        tokio::select! {
            _ = interval.tick() => println!("Another 100ms"),
            msg = &mut rx => {
                println!("Got message: {}", msg.unwrap());
                break;
            }
        }
    }
}

image-20230730182110413

2.1.2 mpsc

mpsc可以有多个发送者,但是只有一个接收者.并且我们可以在创建通道时指定通道的容量,当然也可以使用mpsc::unbounded_channel()创建一个没有容量限制的通道(最大限制为内存大小).我们可以通过clone()得到多个发送者,一旦通道容量占满则发送任务需要等待直到通道有空闲.

fn main() {
    let rt = Runtime::new().unwrap();
    rt.block_on(async {
        let (tx, mut rx) = sync::mpsc::channel(5);
​
        for i in 1..10{
            let tx=tx.clone();
            tokio::spawn(async move{
                if tx.send(i).await.is_err(){
                    println!("receiver closed!");
                    return;
                }
                println!("send:{},now={}",i,now());
            });
        }
        sleep(Duration::from_secs(1)).await;
        drop(tx);
        while let Some(i) = rx.recv().await {
            println!("received: {}", i);
        }
    });
}

image-20230730193451802

前五个消息立即发送,然后通道被占满,直到recv()接收消息后才能继续发送.

2.1.3 broadcast

broadcast也就是广播,在这个通道里可以有多个发送者与接收者,它同样也需要指定通道的容量.对于发送者可以用clone()得到多个新的发送者,而使用subscribe()创建新的接收者.需要注意的是,如果发送的消息超过通道容量时,会将通道内存储的第一个消息给删除,将新的消息添加到尾部.所以如果出现这种情况,就得考虑加快接收者的消费速率了.

#[tokio::main]
async fn main(){
    let (tx,mut rx)=broadcast::channel(10);
    let mut rx2=tx.subscribe();
    tokio::spawn(async move{
       println!("rx receive={}",rx.recv().await.unwrap());
       println!("rx receive={}",rx.recv().await.unwrap());
    });

    tokio::spawn(async move{
        println!("rx2 receive={}",rx2.recv().await.unwrap());
        println!("rx2 receive={}",rx2.recv().await.unwrap());
    });

    tx.send(1314).unwrap();
    tx.send(520).unwrap();
}

image-20230731181748081

什么时候才算一条消息被完全消费了?这个就需要所有存活的接收者都去接收这条消息(克隆消息),此时这个消息才算被完全消费,才能在通道中被移除释放通道空间.

2.1.4 watch

watch只能有一个发送者,但是可以有多个接收者,并且通道的容量只有1.也就是说我们每次send新数据都会直接修改通道中的数据.为了保证读写时的一致性,内部设置了读写锁,在写操作时都会先申请锁.对于接收者,可以使用borrow()方法借用得到通道中数据,并且可以使用changed()判断数据是否更新.

#[tokio::main]
async fn main() {
    let (tx,mut rx)=watch::channel("init");
    tokio::spawn(async move{
        println!("receive={}",*rx.borrow());
        time::sleep(Duration::from_secs(1)).await;
        while rx.changed().await.is_ok(){
           println!("receive={}",*rx.borrow());
       }
    });
    sleep(Duration::from_secs(1)).await;
    tx.send("send message!").unwrap();
}

image-20230731191840917

2.2 Tokio的同步

在多线程中我们需要一些同步机制来防止数据竞争等问题,这里也是一样在Tokio中提供了集中状态同步的机制:

  • Mutex
  • RwLock
  • Notify
  • Barrier
  • Semaphore

和之前讲的多线程安全一样,在这里为了线程间的数据安全,通常用Arc进行封装.

2.2.1 Mutex

互斥锁(Mutex)应该是针对竞态最常用的机制,当多个并发任务修改数据时,可以保证每次仅有一个任务在修改数据,并且修改完毕后才能让另一个任务继续修改.我们可以用new()来创建一个互斥锁,并且用lock()方法申请锁.成功申请锁之后就可以安全的对数据进行操作.

fn main() {
    let rt=Runtime::new().unwrap();
    rt.block_on(async{
        let mutex=Arc::new(sync::Mutex::new(0));
        for i in 0..10{
            let lock=mutex.clone();
            tokio::spawn(async move{
                let mut data=lock.lock().await;
                *data+=1;
                println!("task:{},data:{}",i,data);
            });
        }
        time::sleep(Duration::from_secs(2)).await;
    })
}

image-20230802081749983

并发执行并不保证任务顺序,但是对于数据的增加是严格安全的.不过,由于Tokio的Mutex是基于标准库的封装,因此性能上会有一定的损失.如果追求极致的性能,也可以尝试用标准库的Mutex进行代替.

fn main() {
    let rt=Runtime::new().unwrap();
    rt.block_on(async{
        let mutex=Arc::new(std::sync::Mutex::new(0));
        for i in 0..10{
            let lock=mutex.clone();
            tokio::spawn(async move{
                let mut data=lock.lock().unwrap();
                *data+=1;
                println!("task:{},data:{}",i,data);
            });
        }
        time::sleep(Duration::from_secs(2)).await;
    })
}

image-20230802082125151

改动并不大,但是有一个最大的区别那就是没有了await.如果在异步中存在子任务,并且子任务引用到父任务中的数据,这个是否标准库中的Mutex就可能出现错误,因为标准库中的Mutex申请到锁返回的MutexGuard并没有实现Send.一个简单的解决办法就是用Tokio的Mutex,但是为了性能更好的办法就是尽量将子任务中的需要await的语句想办法转移或者替换(或者在await之前提前drop释放锁).不过Tokio比起标准库更加安全,因为使用Tokio的Mutex不用担心毒锁,当某个任务panic时会直接释放锁,而不会一直占有.

2.2.2 RwLock

读写锁比起互斥锁更加灵活,因为它允许多读,因此在“读多写少”的情况下更建议用读写锁以减少无谓等待,提高性能.不过读写锁容易产生死锁,一旦写锁申请时存在读锁一直没有释放,则会一直等待;如果这个时候再申请读锁,那么程序就会死锁.因此,当读操作结束应该立即drop掉读锁避免死锁产生.

2.2.3 Notify

这里Tokio同样提供了唤醒机制,可以通过notify_one()来唤醒某个实例,也可以通过notify_waiters()唤醒全部等待实例(类似于notify_all()).

下面看一个简单的超时demo

async fn waiter(secs:u64,notify: Arc<Notify>){
    if let Err(_)=tokio::time::timeout(Duration::from_secs(secs),notify.notified()).await{
        println!("secs={},time out!",secs);
    }else{
        println!("secs={},recive notify!",secs);
    }
}

fn main() {
    let rt=Runtime::new().unwrap();
    let notify=Arc::new(Notify::new());
    let notify2=notify.clone();
    let notify3=notify.clone();
    rt.block_on(async move{
        tokio::spawn(async move{
            sleep(Duration::from_secs(2)).await;
            notify.notify_waiters();
            println!("notify!!!");
        });
        tokio::spawn(waiter(1,notify2));
        tokio::spawn(waiter(3,notify3));
        sleep(Duration::from_secs(5)).await;
    });
}

image-20230802091927688

2.2.4 Barrier

屏障可以保证线程间的同步,这里同步的意思是指等待所有线程中的任务完成.比如某个任务分为多个阶段,但是每一阶段必须等待前一个阶段的任务完成,这个时候就需要用到屏障.

2.2.5 Semaphore

信号量用来限制并发任务的数目,对于初始信号量来说每个任务运行都会获得一个信号量,如果信号量都被取走那么接下来的任务就需要等待,直到之前的任务运行结束归还信号量.

fn main() {
    let rt = Runtime::new().unwrap();
    rt.block_on(async {
        // 只有3个信号灯的信号量
        let semaphore = Arc::new(Semaphore::new(3));
​
        // 5个并发任务,每个任务执行前都先获取信号灯
        // 因此,同一时刻最多只有3个任务进行并发
        for i in 1..=5 {
            let semaphore = semaphore.clone();
            tokio::spawn(async move {
                let _permit = semaphore.acquire().await.unwrap();
                println!("{}, {}", i, now());
                time::sleep(Duration::from_secs(1)).await;
            });
        }
​
        time::sleep(Duration::from_secs(3)).await;
    });
}

image-20230802093229014

这样前三个任务就顺利并发,而剩余的两个任务就需要等待.