改进rust代码的35种具体方法-类型(十七)-谨慎在并行运行使用共享状态变量

上一篇文章


即使是最大胆的共享形式,在Rust中也是安全的。——Aaron Turon

官方文档将Rust描述为能够实现"无畏并发",但这个项目将探索为什么(可悲的是)仍然有一些理由害怕并发,即使在Rust中也是如此。

此项目特定于共享状态并行性:不同的执行线程通过共享内存相互通信。线程之间的共享状态通常会带来两个可怕的问题,无论所涉及的语言如何:

  • 数据竞赛:这些可能导致数据损坏。
  • 僵局:这些可能会导致您的程序停止。

这两个问题都很可怕(“造成或可能造成恐怖”),因为它们在实践中可能很难调试:故障以非确定性方式发生,并且通常更有可能在负载下发生——这意味着它们不会出现在单元测试、集成测试或任何其他类型的测试中,但它们确实出现在生产中。

Rust是向前迈出的一大步,因为它完全解决了这两个问题之一。然而,正如我们将看到的,另一个仍然存在。

数据竞赛

让我们从好消息开始,探索数据竞赛和Rust。数据竞赛的精确技术定义因语言而异,但我们可以将关键组件总结如下:

在以下条件下,当两个不同的线程访问相同的内存位置时,数据竞赛被定义为发生:

  • 其中至少有一个是写作。
  • 没有同步机制来强制执行访问的排序。

C++中的数据竞赛

最好用一个例子来说明这方面的基础知识。考虑一个跟踪银行账户的数据结构:

// C++ code.
class BankAccount {
 public:
  BankAccount() : balance_(0) {}

  int64_t balance() const {
    if (balance_ < 0) {
      std::cerr << "** Oh no, gone overdrawn: " << balance_ << "! **\n";
      std::abort();
    }
    return balance_;
  }
  void deposit(uint32_t amount) {
    balance_ += amount;
  }
  bool withdraw(uint32_t amount) {
    if (balance_ < amount) {
      return false;
    }
    // What if another thread changes `balance_` at this point?
    std::this_thread::sleep_for(std::chrono::milliseconds(500));

    balance_ -= amount;
    return true;
  }

 private:
  int64_t balance_;
};

这个例子是C++中的,而不是Rust,原因很快就会变得清晰。然而,同样的一般概念也适用于许多其他(非Rust)语言——Java、Go或Python等。

该类在单线程设置中工作正常,但请考虑多线程设置:

BankAccount account;
account.deposit(1000);

// Start a thread that watches for a low balance and tops up the account.
std::thread payer(pay_in, &account);

// Start 3 threads that each try to repeatedly withdraw money.
std::thread taker(take_out, &account);
std::thread taker2(take_out, &account);
std::thread taker3(take_out, &account);

在这里,几个线程反复尝试从帐户中退出,当帐户运行不足时,还有一个额外的线程会充值帐户:

// Constantly monitor the `account` balance and top it up if low.
void pay_in(BankAccount* account) {
  while (true) {
    if (account->balance() < 200) {
      log("[A] Balance running low, deposit 400");
      account->deposit(400);
    }
    // (The infinite loop with sleeps is just for demonstration/simulation
    // purposes.)
    std::this_thread::sleep_for(std::chrono::milliseconds(5));
  }
}

// Repeatedly try to perform withdrawals from the `account`.
void take_out(BankAccount* account) {
  while (true) {
    if (account->withdraw(100)) {
      log("[B] Withdrew 100, balance now " +
          std::to_string(account->balance()));
    } else {
      log("[B] Failed to withdraw 100");
    }
    std::this_thread::sleep_for(std::chrono::milliseconds(20));
  }
}

最终,会出错:

** Oh no, gone overdrawn: -100! **

这个问题不难发现,特别是withdraw()方法中的有用注释:当涉及多个线程时,平衡值可以在检查和修改之间发生变化。然而,这种现实世界的错误更难发现——特别是如果允许编译器在封面下执行各种技巧和代码重新排序(如C++)。

包括各种sleep调用,目的是人为地提高此错误被击中并因此及早发现的可能性;当在野外遇到这些问题时,它们可能很少和间歇性地发生,这使得它们很难调试。

BankAccount类是线程兼容的,这意味着它可以在多线程环境中使用,只要该类的用户确保对它的访问受某种外部同步机制的约束。

通过添加内部同步操作,该类可以转换为线程安全类,这意味着从多个线程中使用是安全的:1

// C++ code.
class BankAccount {
 public:
  BankAccount() : balance_(0) {}

  int64_t balance() const {
    // Lock mu_ for all of this scope.
    const std::lock_guard<std::mutex> with_lock(mu_);
    if (balance_ < 0) {
      std::cerr << "** Oh no, gone overdrawn: " << balance_ << " **!\n";
      std::abort();
    }
    return balance_;
  }
  void deposit(uint32_t amount) {
    const std::lock_guard<std::mutex> with_lock(mu_);
    balance_ += amount;
  }
  bool withdraw(uint32_t amount) {
    const std::lock_guard<std::mutex> with_lock(mu_);
    if (balance_ < amount) {
      return false;
    }
    balance_ -= amount;
    return true;
  }

 private:
  mutable std::mutex mu_; // protects balance_
  int64_t balance_;
};

内部balance_字段现在由互斥体mu_保护:一个同步对象,可确保一次只有一个线程可以成功保持互斥。调用者可以通过调用std::mutex::lock()获取互斥量;std::mutex::lock()的第二个和后续调用者将被阻止,直到原始调用者调用std::mutex::unlock(),然后其中一个被阻止的线程将取消阻止并通过std::mutex::lock()进行。

现在,对平衡的所有访问都与持有的互斥体一起进行,确保其值在检查和修改之间保持一致。std::lock_guard也值得强调:它是一个RAII类,在创建时调用lock()),在销毁时调用unlock())。这确保了在作用域退出时解锁互斥体,从而减少了在平衡手动lock()和解unlock()调用时犯错误的可能性。

然而,这里的线程安全性仍然脆弱;只需要对类进行一次错误的修改:

// Add a new C++ method...
void pay_interest(int32_t percent) {
  // ...but forgot about mu_
  int64_t interest = (balance_ * percent) / 100;
  balance_ += interest;
}

并且线程安全性被破坏了。


Rust中的数据竞赛

对于一本关于Rust的书,这个项目涵盖了很多C++,所以请考虑将这个类直接翻译成Rust:

pub struct BankAccount {
    balance: i64,
}

impl BankAccount {
    pub fn new() -> Self {
        BankAccount { balance: 0 }
    }
    pub fn balance(&self) -> i64 {
        if self.balance < 0 {
            panic!("** Oh no, gone overdrawn: {}", self.balance);
        }
        self.balance
    }
    pub fn deposit(&mut self, amount: i64) {
        self.balance += amount
    }
    pub fn withdraw(&mut self, amount: i64) -> bool {
        if self.balance < amount {
            return false;
        }
        self.balance -= amount;
        true
    }
}

以及试图永远向帐户付款或从帐户中提款的功能:

pub fn pay_in(account: &mut BankAccount) {
    loop {
        if account.balance() < 200 {
            println!("[A] Running low, deposit 400");
            account.deposit(400);
        }
        std::thread::sleep(std::time::Duration::from_millis(5));
    }
}

pub fn take_out(account: &mut BankAccount) {
    loop {
        if account.withdraw(100) {
            println!("[B] Withdrew 100, balance now {}", account.balance());
        } else {
            println!("[B] Failed to withdraw 100");
        }
        std::thread::sleep(std::time::Duration::from_millis(20));
    }
}

这在单线程上下文中工作正常——即使该线程不是主线程:

{
    let mut account = BankAccount::new();
    let _payer = std::thread::spawn(move || pay_in(&mut account));
    // At the end of the scope, the `_payer` thread is detached
    // and is the sole owner of the `BankAccount`.
}

但天真地试图跨多个线程使用BankAccount

{
    let mut account = BankAccount::new();
    let _taker = std::thread::spawn(move || take_out(&mut account));
    let _payer = std::thread::spawn(move || pay_in(&mut account));
}

立即犯规编译器:

error[E0382]: use of moved value: `account`
   --> src/main.rs:102:41
    |
100 | let mut account = BankAccount::new();
    |     ----------- move occurs because `account` has type
    |                 `broken::BankAccount`, which does not implement the
    |                 `Copy` trait
101 | let _taker = std::thread::spawn(move || take_out(&mut account));
    |                                 -------               ------- variable
    |                                 |                         moved due to
    |                                 |                         use in closure
    |                                 |
    |                                 value moved into closure here
102 | let _payer = std::thread::spawn(move || pay_in(&mut account));
    |                                 ^^^^^^^             ------- use occurs due
    |                                 |                        to use in closure
    |                                 |
    |                                 value used here after move

借款检查器的规则(改进rust代码的35种具体方法-类型(十五)-借用检查器)阐明了问题:同一项目有两个可变引用,一个比允许的多一个。借入检查器的规则是,您可以对一个项目进行单个可变引用,或多个(不可变)引用,但不能同时同时进行。

这与本项目开始时数据竞赛的定义产生了奇怪的共鸣:强制执行只有一个作者或多个读者(但绝不是两者兼而有之)意味着不可能有数据竞赛。通过强制执行内存安全,Rust“免费”获得线程安全。

与C++一样,需要某种同步才能使该struct线程安全。最常见的机制也称为Mutex,但Rust版本“包装”受保护的数据,而不是独立的对象(如C++):

pub struct BankAccount {
    balance: std::sync::Mutex<i64>,
}

Mutex泛型上的lock()方法返回具有RAII行为的MutexGuard对象,如C++的std::lock_guard:当保护被丢弃时,互斥体在范围结束时自动释放。(与C++相反,Rust的Mutex没有手动获取或释放互斥的方法,因为它们会让开发人员面临忘记保持这些调用完全同步的危险。)

更准确地说,lock()实际上返回一个包含MutexGuardResult,以应对Mutex中毒的可能性。如果线程在保持锁时出现故障,就会发生中毒,因为这可能意味着任何互斥保护的不变量都无法再依赖。在实践中,锁中毒非常罕见(并且程序在发生时终止是足够可取的),通常只是.unwrap()Result

MutexGuard对象还充当Mutex所包含数据的代理,通过实现DerefDerefMut特征(复杂类型使用构建器),允许它同时用于读取操作:

impl BankAccount {
    pub fn balance(&self) -> i64 {
        let balance = *self.balance.lock().unwrap();
        if balance < 0 {
            panic!("** Oh no, gone overdrawn: {}", balance);
        }
        balance
    }
}

对于写入操作:

impl BankAccount {
    // Note: no longer needs `&mut self`.
    pub fn deposit(&self, amount: i64) {
        *self.balance.lock().unwrap() += amount
    }
    pub fn withdraw(&self, amount: i64) -> bool {
        let mut balance = self.balance.lock().unwrap();
        if *balance < amount {
            return false;
        }
        *balance -= amount;
        true
    }
}

这些方法的签名中潜伏着一个有趣的细节:尽管它们正在修改BankAccount的余额,但这些方法现在采用&self而不是&mut self。这是不可避免的:如果多个线程要保留对同一BankAccount的引用,根据借款检查器的规则,这些引用最好不要是可变的。这也是第复杂类型使用构建器中描述的内部可变性模式的另一个实例:借入检查有效地从编译时移动到运行时,但现在具有跨线程同步行为。如果已存在可变引用,则尝试获取第二个块,直到删除第一个引用。

Mutex中总结共享状态可以调整借入检查器,但仍有终身问题(生命周期)需要修复:

{
    let account = BankAccount::new();
    let taker = std::thread::spawn(|| take_out(&account));
    let payer = std::thread::spawn(|| pay_in(&account));
    // At the end of the scope, `account` is dropped but
    // the `_taker` and `_payer` threads are detached and
    // still hold (immutable) references to `account`.
}
error[E0373]: closure may outlive the current function, but it borrows `account`
              which is owned by the current function
   --> src/main.rs:206:40
    |
206 |     let taker = std::thread::spawn(|| take_out(&account));
    |                                    ^^           ------- `account` is
    |                                    |                     borrowed here
    |                                    |
    |                                    may outlive borrowed value `account`
    |
note: function requires argument type to outlive `'static`
   --> src/main.rs:206:21
    |
206 |     let taker = std::thread::spawn(|| take_out(&account));
    |                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
help: to force the closure to take ownership of `account` (and any other
      referenced variables), use the `move` keyword
    |
206 |     let taker = std::thread::spawn(move || take_out(&account));
    |                                    ++++
error[E0373]: closure may outlive the current function, but it borrows `account`
              which is owned by the current function
   --> src/main.rs:207:40
    |
207 |     let payer = std::thread::spawn(|| pay_in(&account));
    |                                    ^^         ------- `account` is
    |                                    |                  borrowed here
    |                                    |
    |                                    may outlive borrowed value `account`
    |
note: function requires argument type to outlive `'static`
   --> src/main.rs:207:21
    |
207 |     let payer = std::thread::spawn(|| pay_in(&account));
    |                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
help: to force the closure to take ownership of `account` (and any other
      referenced variables), use the `move` keyword
    |
207 |     let payer = std::thread::spawn(move || pay_in(&account));
    |                                    ++++

错误消息清楚地表明了问题:BankAccount将在块末尾被丢弃,但有两个新线程引用了它,之后可能会继续运行。(编译器关于如何解决问题的建议不太有帮助——如果BankAccount项目被移到第一个关闭中,第二个关闭将不再用于接收对它的引用!)

确保对象在所有引用消失之前保持活动状态的标准工具是引用计数指针,而Rust用于多线程使用的变体是std::sync::Arc:

let account = std::sync::Arc::new(BankAccount::new());
account.deposit(1000);

let account2 = account.clone();
let _taker = std::thread::spawn(move || take_out(&account2));

let account3 = account.clone();
let _payer = std::thread::spawn(move || pay_in(&account3));

每个线程都获得自己的引用计数指针副本,并移动到闭包中,只有当引用计数降至零时,基础BankAccount才会被删除。Arc<Mutex<T>>的这种组合在使用共享状态并行的Rust程序中很常见。

退后一步,观察Rust完全避免了困扰其他语言多线程编程的数据竞赛问题。当然,这个好消息仅限于安全的Rust——unsafe代码,特别是FFI边界(可能不是无数据竞赛——但它仍然是一个了不起的现象。

特征标记

有两个标准特征会影响线程之间Rust对象的使用。这两个特征都是标记特征项目10-使用回数变换而不是显式循环),没有相关方法,但在多线程场景中对编译器具有特殊意义:

  • Send特征表示某种类型的项目在线程之间可以安全地传输;这种类型的项目的所有权可以从一个线程传递到另一个线程。
  • Sync特征表明,根据借阅检查器的规则,多个线程可以安全地访问一种类型的项目。

另一种说法是观察,Send意味着T可以在线程之间传输,Sync意味着&T可以在线程之间传输。

这两个特征都是自动特征:编译器会自动为新类型派生它们,只要该类型的组成部分也实现Send/Sync

大多数安全类型都实现了Send和Sync,因此更容易理解哪些类型不实现这些特性(以impl !Sync for Type的形式编写)。

不实现Send的类型是只能在单个线程中使用的类型。这方面的规范示例是不同步的引用计数指针Rc<T>。这种类型的实现明确假设了单线程使用(用于速度);没有尝试同步内部引用计数以用于多线程使用。因此,不允许在线程之间传输Rc<T>;在这种情况下,请使用Arc<T>(及其额外的同步开销)。

不实现Sync的类型是通过非mut引用从多个线程使用是不安全的类型(因为借阅检查器将确保永远不会有多个mut引用)。这方面的典型示例是以非同步方式提供内部可变性的类型,例如Cell<T>RefCell<T>。使用Mutex<T>RwLock<T>在多线程环境中提供内部可变性。

*const T*mut T等原始指针类型也既不实现Send也不Sync

死锁

现在来个坏消息。尽管Rust已经解决了数据种族问题(如前所述),但它仍然容易受到共享状态:死锁的多线程代码的第二个可怕问题的影响。

考虑一个简化的多人游戏服务器,作为多线程应用程序实现,为许多玩家并行服务。两个核心数据结构可能是玩家的集合,按用户名索引,以及正在进行的游戏集合,由一些唯一标识符索引:

struct GameServer {
    // Map player name to player info.
    players: Mutex<HashMap<String, Player>>,
    // Current games, indexed by unique game ID.
    games: Mutex<HashMap<GameId, Game>>,
}

这两个数据结构都受到Mutex保护,因此不受数据竞赛的影响。然而,操纵两种数据结构的代码会带来潜在的问题。两者之间的单一互动可能正常:

impl GameServer {
    /// Add a new player and join them into a current game.
    fn add_and_join(&self, username: &str, info: Player) -> Option<GameId> {
        // Add the new player.
        let mut players = self.players.lock().unwrap();
        players.insert(username.to_owned(), info);

        // Find a game with available space for them to join.
        let mut games = self.games.lock().unwrap();
        for (id, game) in games.iter_mut() {
            if game.add_player(username) {
                return Some(id.clone());
            }
        }
        None
    }
}

然而,两个独立锁定的数据结构之间的第二次交互是问题开始的地方:

impl GameServer {
    /// Ban the player identified by `username`, removing them from
    /// any current games.
    fn ban_player(&self, username: &str) {
        // Find all games that the user is in and remove them.
        let mut games = self.games.lock().unwrap();
        games
            .iter_mut()
            .filter(|(_id, g)| g.has_player(username))
            .for_each(|(_id, g)| g.remove_player(username));

        // Wipe them from the user list.
        let mut players = self.players.lock().unwrap();
        players.remove(username);
    }
}

要理解这个问题,想象一下使用这两种方法的两个单独的线程,它们的执行顺序如下。

线程死锁序列
线程1线程2
输入add_and_join()并立即获取players锁。
输入ban_player()并立即获取games锁。
尝试获取games锁;这由线程2持有,所以线程1块。    
尝试获取players锁;这由线程1持有,因此线程2块。

此时,程序陷入僵局:任何线程都不会进展,任何其他线程都不会对两个Mutex保护的数据结构中的任何一个进行任何一个。

其根本原因是锁反转:一个功能以playersthengames的顺序获取锁,而另一个功能使用相反的顺序(games然后players)。这是一个更一般问题的简单示例;更长的嵌套锁链(线程1获取锁A,然后B,然后它试图获取C;线程2获取C,然后尝试获取A)和跨越更多线程(线程1锁A,然后B;线程2锁B,然后C;线程3锁C,然后A)会出现同样的情况。

解决这个问题的简单尝试涉及缩小锁的范围,因此没有同时持有两把锁的意义:

/// Add a new player and join them into a current game.
fn add_and_join(&self, username: &str, info: Player) -> Option<GameId> {
    // Add the new player.
    {
        let mut players = self.players.lock().unwrap();
        players.insert(username.to_owned(), info);
    }

    // Find a game with available space for them to join.
    {
        let mut games = self.games.lock().unwrap();
        for (id, game) in games.iter_mut() {
            if game.add_player(username) {
                return Some(id.clone());
            }
        }
    }
    None
}
/// Ban the player identified by `username`, removing them from
/// any current games.
fn ban_player(&self, username: &str) {
    // Find all games that the user is in and remove them.
    {
        let mut games = self.games.lock().unwrap();
        games
            .iter_mut()
            .filter(|(_id, g)| g.has_player(username))
            .for_each(|(_id, g)| g.remove_player(username));
    }

    // Wipe them from the user list.
    {
        let mut players = self.players.lock().unwrap();
        players.remove(username);
    }
}

(更好的版本是将players数据结构的操作封装到add_player()remove_player()助手方法中,以减少忘记关闭范围的机会。)

这解决了死锁问题,但留下了数据一致性问题:给定如下表所示的执行序列,playersgames数据结构可以彼此不同步。

状态不一致序列
线程1线程2
输入add_and_join("Alice")并将Alice添加到players数据结构中(然后释放players锁)。
进入ban_player("Alice")并从所有games删除Alice(然后释放games锁)。
players数据结构中删除Alice;线程1已经释放了锁,所以这不会阻止。
继续并获取games锁(已由线程2发布)。按住锁,将“爱丽丝”添加到正在进行的游戏中。

在这一点上,根据players的数据结构,有一个游戏包括一个不存在的玩家!

问题的核心是,有两个数据结构需要保持同步。做到这一点的最好方法是拥有一个涵盖两者的单一同步原语:

struct GameState {
    players: HashMap<String, Player>,
    games: HashMap<GameId, Game>,
}

struct GameServer {
    state: Mutex<GameState>,
    // ...
}

建议

避免共享状态并行性引起的问题的最明显建议是避免共享国家并行性。Rust书引用了Go语言文档:“不要通过共享内存进行通信;相反,通过通信来共享内存”。

Go语言中内置了适合于此的通道;对于Rust,等效的功能包含在标准库的std::sync::mpsc模块中:channel()函数返回一个(Sender, Receiver)对,允许特定类型的值在线程之间通信。

如果无法避免共享状态并发,那么有一些方法可以减少编写容易陷入僵局的代码的机会:

  • 将必须相互保持一致的数据结构放在单个锁下
  • 保持锁定范围小而明显;在可能的情况下,使用辅助方法在相关锁下获取和设置东西。
  • 避免在持有锁的情况下调用闭包;这使得代码受制于将来添加到代码库的任何闭包的摆布。
  • 同样,避免将MutexGuard返还给来电者:从僵局的角度来看,这就像分发一把上膛的枪。
  • 在您的CI系统中包括死锁检测工具,例如no_deadlocksThreadSanitizerparking_lot::deadlock
  • 作为最后手段:设计、文档、测试和监管一个锁定层次结构,描述允许/要求的锁定顺序。这应该是最后的手段,因为任何依赖工程师从不犯错的战略,从长远来看都可能注定要失败。

更抽象地说,多线程代码是应用以下一般建议的理想场所:更喜欢如此简单以至于显然没有错误的代码,而不是如此复杂以至于没有明显错误的代码。


1 行为的第三类是线程敌对:在多线程环境中危险的代码,即使对它的所有访问都是外部同步的。

2 Clang C++编译器包括一个-Wthread-safety选项,有时也称为注释,它允许用关于哪些互斥体保护哪些数据的信息对数据进行注释,并用有关它们获得的锁的信息对函数进行注释。当这些不变量被破坏时,这会给出编译时错误,如Rust;然而,首先没有什么可以强制使用这些注释——例如,当线程兼容库首次在多线程环境中使用时。

  • 7
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值