Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

无畏并发

并发编程(Concurrent programming),其中程序的不同部分独立执行,以及并行编程(parallel programming),其中程序的不同部分同时执行,在计算机可以更轻松地利用多个处理器的时代变得越来越重要。历史上,这两种编程范式都很困难且容易出错:X 语言希望改变这一点。

最初,X 语言团队认为确保内存安全和防止并发问题是两个需要用不同方法解决的不同挑战。随着时间的推移,团队发现所有权和类型系统实际上是一组强大的工具,可以帮助同时管理内存安全和并发问题!通过利用所有权和类型检查,X 语言中的许多并发错误在编译时是错误,而不是运行时错误。因此,你可以花更多时间让程序的并发部分按预期工作,而不是花时间追踪错误。

我们将在本章中讨论的概念是:

  • 如何使用线程同时运行多段代码
  • 如何在线程之间使用消息传递并发,使用通道发送数据
  • 如何使用状态共享并发,其中多个线程可以访问同一块数据
  • SyncSend trait,它们使 X 语言的并发保证扩展到用户定义的类型以及标准库提供的类型

让我们开始吧!

使用线程同时运行代码

在大多数现代操作系统中,执行的程序代码在进程中运行,操作系统一次管理多个进程。在你的程序中,你可以有同时运行的独立部分。运行这些独立部分的功能称为线程(thread)。例如,Web 服务器可以有多个线程,这样它可以同时响应多个请求。

将代码拆分为程序中的多个线程可以同时完成更多工作,但这也增加了复杂性。因为线程是同时运行的,所以无法保证线程中的代码部分的运行顺序。这可能会导致问题,例如:

  • 竞争条件(Race conditions),其中线程以不一致的顺序访问数据或资源
  • 死锁(Deadlocks),其中两个线程都在等待对方释放它们需要的资源,阻止两个线程继续
  • 只在特定情况下发生的错误,很难可靠地复现和修复

X 语言试图减轻使用线程的负面影响,但在多线程上下文中编程仍然需要仔细思考,并且代码结构需要与单线程程序不同。

编程语言以几种不同的方式实现线程。许多操作系统提供一个 API 用于创建新线程。这种由编程语言使用操作系统 API 创建线程的模型通常称为 1:1,意思是一个操作系统线程对应一个语言线程。

还有绿色线程(green threads)模型,其中语言创建自己的线程。这些绿色线程在被称为 M:N 模型的一定数量的操作系统线程上运行,其中 M 个绿色线程对应 N 个操作系统线程,其中 M 和 N 不一定相同。

每个模型都有其优点和缺点,对 X 语言最重要的优点是运行时大小。运行时(Runtime)指的是语言包含在每个二进制文件中的代码;对于某些语言,这段代码可能很大,对于其他语言可能很小。通常,较小的运行时大小的权衡是更少的功能。X 语言团队需要一个几乎没有运行时的语言,同时仍然尽可能保留功能。

早期,X 语言团队尝试实现绿色线程,但发现很难使它们在所有目标平台上都能很好地工作。因此,X 语言标准库仅提供 1:1 线程的实现。X 语言的设计使得你可以在这个基础上实现 M:N 线程,如果你愿意牺牲一些性能以获得诸如更好的调度控制和更低的操作系统线程开销等功能。

让我们通过看看标准库提供的线程 API 来探索 X 语言中的线程!

使用 spawn 创建新线程

要创建新线程,我们调用 thread::spawn 函数并向其传递一个闭包(我们在第 13 章中讨论过),其中包含我们要在新线程中运行的代码。示例展示了如何从主线程打印一些文本,并在新线程中打印其他一些文本:

import std::thread
import std::time::Duration

function main() {
  thread::spawn(function() {
    for i in 1..10 {
      println("线程中的数字 {}", i)
      thread::sleep(Duration::from_millis(1))
    }
  })

  for i in 1..5 {
    println("主线程中的数字 {}", i)
    thread::sleep(Duration::from_millis(1))
  }
}

注意,使用此代码,新线程将在主线程完成时立即停止,无论新线程是否完成打印其所有数字!输出可能每次看起来都有点不同,但它应该类似于:

主线程中的数字 1
线程中的数字 1
主线程中的数字 2
线程中的数字 2
主线程中的数字 3
线程中的数字 3
主线程中的数字 4
线程中的数字 4
主线程中的数字 5
线程中的数字 5

线程可能会交替,但这不能保证:这取决于你的操作系统如何调度线程。在这里,主线程先打印,即使新线程的打印语句在代码中首先出现。即使我们告诉新线程一直打印到 i 是 9,它只在主线程关闭之前走到 5。

请注意,你从 thread::spawn 获得的值类型是 JoinHandle,它是一个拥有值的类型,当你在其上调用 join 方法时,它将等待线程完成。示例修改了示例中的代码,以使用 JoinHandlejoin 方法,并确保新线程在主线程退出之前完成:

import std::thread
import std::time::Duration

function main() {
  let handle = thread::spawn(function() {
    for i in 1..10 {
      println("线程中的数字 {}", i)
      thread::sleep(Duration::from_millis(1))
    }
  })

  for i in 1..5 {
    println("主线程中的数字 {}", i)
    thread::sleep(Duration::from_millis(1))
  }

  handle.join().unwrap()
}

通过在句柄上调用 join,我们可以阻塞当前线程,直到句柄表示的线程完成。阻塞(Blocking)一个线程意味着阻止该线程继续工作或退出。根据我们是否将调用 join 放在主线程的 for 循环之前或之后,我们的输出将不再交替!尝试看看;这就是线程调度工作的方式。

这就是使用线程的基本原理!让我们继续使用线程,通过使用 move 关键字与闭包一起从一个线程向另一个线程移动数据。

使用 move 闭包和线程

我们经常使用 thread::spawn 中的 move 关键字与闭包一起,以允许闭包从环境中获取数据的所有权,从而允许我们在一个线程中使用来自另一个线程的数据。

在第 13 章中,我们提到在闭包参数列表之前可以使用 move 关键字来强制闭包获取它使用的环境中值的所有权。这种技术在创建新线程时特别有用,以便我们将数据从一个线程移动到另一个线程。

请注意,在示例中,我们传递给 thread::spawn 的闭包没有任何来自环境的参数:我们没有在新线程运行的代码中使用来自主线程的任何数据。要在新线程中使用来自主线程的数据,闭包需要捕获它需要的值。示例显示了一个尝试在新线程中创建并使用来自主线程的向量引用的闭包。但是,这还不能工作,原因我们马上会解释。

import std::thread

function main() {
  let v = [1, 2, 3]

  let handle = thread::spawn(function() {
    println("这是一个向量: {:?}", v)
  })

  handle.join().unwrap()
}

闭包使用了 v,所以闭包会捕获 v 并使其成为闭包环境的一部分。因为 thread::spawn 在新线程中运行这个闭包,所以我们需要在新线程中访问 v。但是,当我们编译这个示例时,我们会得到以下错误:

error: 闭包可能比借用值活得更久

X 语言告诉我们,闭包不能借用 v,因为它不知道新线程会活多久,所以它不知道对 v 的引用是否总是有效。

为了修复这个编译错误,让我们使用我们在第 13 章中学到的 move 关键字,强制闭包获取它正在使用的值的所有权,而不是让 X 语言推断它应该借用值。示例展示了如何修改示例以添加 move 关键字到闭包,这将解决这个问题:

import std::thread

function main() {
  let v = [1, 2, 3]

  let handle = thread::spawn(move function() {
    println("这是一个向量: {:?}", v)
  })

  handle.join().unwrap()
}

通过在闭包前面添加 move 关键字,我们强制闭包获取它正在使用的值的所有权,而不是让 X 语言推断它应该借用。这修复了我们的问题!

既然我们已经看到了 thread::spawnJoinHandle 的基本原理,并且我们已经看到了如何将数据从一个线程移动到另一个线程,让我们看看在线程之间通信的一些方法。