并发编程

并发是什么?引用Rob Pike的经典描述:

并发是同一时间应对多件事情的能力

其实在我们身边就有很多并发的事情,比如一边上课,一边发短信;一边给小孩喂奶,一边看电视,只要你细心留意,就会发现许多类似的事。相应地,在软件的世界里,我们也会发现这样的事,比如一边写博客,一边听音乐;一边看网页,一边下载软件等等。显而易见这样会节约不少时间,干更多的事。然而一开始计算机系统并不能同时处理两件事,这明显满足不了我们的需要,后来慢慢提出了多进程,多线程的解决方案,再后来,硬件也发展到了多核多CPU的地步。在硬件和系统底层对并发的支持也来越多,相应地,各大编程语言也对并发处理提供了强力的支持,作为新兴语言的Rust,自然也支持并发编程。那么本章就将引领大家一览Rust并发编程的相关知识,从线程开始,逐步尝试进行数据交互,同步协作,最后进入到并行实现,一步一步揭开Rust并发编程的神秘面纱。由于本书主要介绍的是Rust语言的使用,所以本章不会对并发编程相关理论知识进行全面而深入地探讨——要真那样地话,一本书都不够介绍的,而是更侧重于介绍用Rust语言怎么实现基本的并发。

首先我们会介绍线程的使用,线程是基本的执行单元,其重要性不言而喻,Rust程序就是由一堆线程组成的。在当今多核多CPU已经普及的情况下,各种大数据分析和并行计算又让线程焕发出了更耀眼的光芒。如果对线程不甚了解,请先参阅操作系统相关的书籍,此处不过多介绍。然后介绍一些在解决并发问题时,需要处理的数据传递和协作的实现,比如消息传递,同步和共享内存。最后简要介绍Rust中并行的实现。

24.1 线程创建与结束

相信线程对大家而言,一点也不陌生,在当今多CPU多核已经普及的情况下,大数据分析与并行计算都离不开它,几乎所有的语言都支持它,所有的进程都是由一个或多个线程所组成的。既然如此重要,接下来我们就先来看一下在Rust中如何创建一个线程,然后线程又是如何结束的。

Rust对于线程的支持,和C++11一样,都是放在标准库中来实现的,详情请参见std::thread,好在Rust从一开始就这样做了,不用像C++那样等呀等。在语言层面支持后,开发者就不用那么苦兮兮地处理各平台的移植问题。通过Rust的源码可以看到,std::thread其实就是对不同平台的线程操作的封装,相关API的实现都是调用操作系统的API来实现的,从而提供了线程操作的统一接口。对于我而言,能够这样简单快捷地操作原生线程,身上的压力一下轻了不少。

创建线程

首先,我们看一下在Rust中如何创建一个原生线程(native thread)。std::thread提供了两种创建方式,都非常简单,第一种方式是通过spawn函数来创建,参见下面的示例代码:

use std::thread;

fn main() {
    // 创建一个线程
    let new_thread = thread::spawn(move || {
        println!("I am a new thread.");
    });
    // 等待新建线程执行完成
    new_thread.join().unwrap();
}

执行上面这段代码,将会看到下面的输出结果:

I am a new thread.

就5行代码,少得不能再少,最关键的当然就是调用spawn函数的那行代码。使用这个函数,记得要先use std::thread。注意spawn函数需要一个函数作为参数,且是FnOnce类型,如果已经忘了这种类型的函数,请学习或回顾一下函数和闭包章节。main函数最后一行代码即使不要,也能创建线程(关于join函数的作用和使用在后续小节详解,此处你只要知道它可以用来等待线程执行完成即可),可以去掉或者注释该行代码试试。这样的话,运行结果可能没有任何输出,具体原因后面详解。

接下来我们使用第二种方式创建线程,它比第一种方式稍微复杂一点,因为功能强大一点,可以在创建之前设置线程的名称和堆栈大小,参见下面的代码:

use std::thread;

fn main() {
    // 创建一个线程,线程名称为 thread1, 堆栈大小为4k
    let new_thread_result = thread::Builder::new()
                            .name("thread1".to_string())
                            .stack_size(4*1024*1024).spawn(move || {
        println!("I am thread1.");
    });
    // 等待新创建的线程执行完成
    new_thread_result.unwrap().join().unwrap();
}

执行上面这段代码,将会看到下面的输出结果:

I am thread1.

通过和第一种方式的实现代码比较可以发现,这种方式借助了一个Builder类来设置线程名称和堆栈大小,除此之外,Builderspawn函数的返回值是一个Result,在正式的代码编写中,可不能像上面这样直接unwrap.join,应该判定一下。后面也会有很多类似的演示代码,为了简单说明不会做的很严谨。

以上就是Rust创建原生线程的两种不同方式,示例代码有点然并卵的意味,但是你可以稍加修改,就可以变得更加有用,试试吧。

线程结束

此时,我们已经知道如何创建一个新线程了,创建后,不管你见或者不见,它就在那里,那么它什么时候才会消亡呢?自生自灭,亦或者被干掉?如果接触过一些系统编程,应该知道有些操作系统提供了粗暴地干掉线程的接口,看它不爽,直接干掉,完全可以不理会新建线程的感受。是否感觉很爽,但是Rust不会再让这样爽了,因为std::thread并没有提供这样的接口,为什么呢?如果深入接触过并发编程或多线程编程,就会知道强制终止一个运行中的线程,会出现诸多问题。比如资源没有释放,引起状态混乱,结果不可预期。强制干掉那一刻,貌似很爽地解决问题了,然而可能后患无穷。Rust语言的一大特性就是安全,是绝对不允许这样不负责任的做法的。即使在其他语言提供了类似的接口,也不应该滥用。

那么在Rust中,新建的线程就只能让它自身自灭了吗?其实也有两种方式,首先介绍大家都知道的自生自灭的方式,线程执行体执行完成,线程就结束了。比如上面创建线程的第一种方式,代码执行完println!("I am a new thread.");就结束了。 如果像下面这样:

use std::thread;

fn main() {
    // 创建一个线程
    let new_thread = thread::spawn(move || {
        loop {
            println!("I am a new thread.");
        }
    });
    // 等待新创建的线程执行完成
    new_thread.join().unwrap();
}

线程就永远都不会结束,如果你用的还是古董电脑,运行上面的代码之前,请做好心理准备。在实际代码中,要时刻警惕该情况的出现(单核情况下,CPU占用率会飙升到100%),除非你是故意为之。

线程结束的另一种方式就是,线程所在进程结束了。我们把上面这个例子稍作修改:

use std::thread;

fn main() {
    // 创建一个线程
    thread::spawn(move || {
        loop {
            println!("I am a new thread.");
        }
    });

    // 不等待新创建的线程执行完成
    // new_thread.join().unwrap();
}

同上面的代码相比,唯一的差别在于main函数的最后一行代码被注释了,这样主线程就不用等待新建线程了,在创建线程之后就执行完了,其所在进程也就结束了,从而新建的线程也就结束了。此处,你可能有疑问:为什么一定是进程结束导致新建线程结束?也可能是创建新线程的主线程结束而导致的?事实到底如何,我们不妨验证一下:

use std::thread;

fn main() {
    // 创建一个线程
    let new_thread = thread::spawn(move || {
        // 再创建一个线程
        thread::spawn(move || {
            loop {
                println!("I am a new thread.");
            }
        })
    });

    // 等待新创建的线程执行完成
    new_thread.join().unwrap();
    println!("Child thread is finish!");

    // 睡眠一段时间,看子线程创建的子线程是否还在运行
    thread::sleep_ms(100);
}

这次我们在新建线程中还创建了一个线程,从而第一个新建线程是父线程,主线程在等待该父线程结束后,主动睡眠一段时间。这样做有两个目的,一是确保整个程序不会马上结束;二是如果子线程还存在,应该会获得执行机会,以此来检验子线程是否还在运行,下面是输出结果:

Child thread is finish!
I am a new thread.
I am a new thread.
......

结果表明,在父线程结束后,其创建的子线程还活着,这并不会因为父线程结束而结束。这个还是比较符合自然规律的,要不然真会断子绝孙,人类灭绝。所以导致线程结束的第二种方式,是结束其所在进程。到此为止,我们已经把线程的创建和结束都介绍完了,那么接下来我们会介绍一些更有趣的东西。但是在此之前,请先考虑一下下面的练习题。

练习题:

有一组学生的成绩,我们需要对它们评分,90分及以上是A,80分及以上是B,70分及以上是C,60分及以上为D,60分以下为E。现在要求用Rust语言编写一个程序来评分,且评分由新建的线程来做,最终输出每个学生的学号,成绩,评分。学生成绩单随机产生,学生人数100位,成绩范围为[0,100],学号依次从1开始,直到100。