并发运行时中的常规最佳做法

本文档介绍适用于并发运行时多个区域的最佳做法。

章节

本文档包含以下各节:

尽可能使用协作同步构造

并发运行时提供许多不需要外部同步对象的并发安全构造。 例如,concurrency::concurrent_vector 类可提供并发安全的追加和元素访问操作。 在这里,并发安全意味着指针或迭代器始终有效。 它不保证元素初始化或特定的遍历顺序。 但是,如果你需要对资源进行独占访问,则运行时可提供 concurrency::critical_sectionconcurrency::reader_writer_lockconcurrency::event 类。 这些类型以协作的形式工作;因此,当第一个任务等待数据时,任务计划程序可将处理资源重新分配到另一个上下文。 如果可能,请使用这些同步类型而不是其他同步机制(例如 Windows API 提供的机制),这些机制不是以协作的方式工作。 有关这些同步类型和代码示例的详细信息,请参阅同步数据结构将同步数据结构与 Windows API 进行比较

[返回页首]

避免不听从安排的长时间运行的任务

由于任务计划程序以协作的方式工作,它不会在任务之间提供公平性。 因此,一个任务可以阻止其他任务启动。 尽管在某些情况下这是可以接受的,但在其他情况下,这可能会导致死锁或资源枯竭。

以下示例执行的任务超过了分配的处理资源数。 第一个任务不听从任务计划程序的安排,因此第二个任务在第一个任务完成之前不会启动。

// cooperative-tasks.cpp
// compile with: /EHsc
#include <ppl.h>
#include <iostream>
#include <sstream>

using namespace concurrency;
using namespace std;

// Data that the application passes to lightweight tasks.
struct task_data_t
{
   int id;  // a unique task identifier.
   event e; // signals that the task has finished.
};

// A lightweight task that performs a lengthy operation.
void task(void* data)
{   
   task_data_t* task_data = reinterpret_cast<task_data_t*>(data);

   // Create a large loop that occasionally prints a value to the console.
   int i;
   for (i = 0; i < 1000000000; ++i)
   {
      if (i > 0 && (i % 250000000) == 0)
      {
         wstringstream ss;
         ss << task_data->id << L": " << i << endl;
         wcout << ss.str();
      }
   }
   wstringstream ss;
   ss << task_data->id << L": " << i << endl;
   wcout << ss.str();

   // Signal to the caller that the thread is finished.
   task_data->e.set();
}

int wmain()
{
   // For illustration, limit the number of concurrent 
   // tasks to one.
   Scheduler::SetDefaultSchedulerPolicy(SchedulerPolicy(2, 
      MinConcurrency, 1, MaxConcurrency, 1));

   // Schedule two tasks.

   task_data_t t1;
   t1.id = 0;
   CurrentScheduler::ScheduleTask(task, &t1);

   task_data_t t2;
   t2.id = 1;
   CurrentScheduler::ScheduleTask(task, &t2);

   // Wait for the tasks to finish.

   t1.e.wait();
   t2.e.wait();
}

该示例产生下面的输出:

1: 250000000 1: 500000000 1: 750000000 1: 1000000000 2: 250000000 2: 500000000 2: 750000000 2: 1000000000

可通过多种方式在两个任务之间实现协作。 一种方式是在长时间运行的任务中偶尔听从任务计划程序的安排。 以下示例修改 task 函数,以调用 concurrency::Context::Yield 方法来让任务计划程序先安排运行另一个任务。

// A lightweight task that performs a lengthy operation.
void task(void* data)
{   
   task_data_t* task_data = reinterpret_cast<task_data_t*>(data);

   // Create a large loop that occasionally prints a value to the console.
   int i;
   for (i = 0; i < 1000000000; ++i)
   {
      if (i > 0 && (i % 250000000) == 0)
      {
         wstringstream ss;
         ss << task_data->id << L": " << i << endl;
         wcout << ss.str();

         // Yield control back to the task scheduler.
         Context::Yield();
      }
   }
   wstringstream ss;
   ss << task_data->id << L": " << i << endl;
   wcout << ss.str();

   // Signal to the caller that the thread is finished.
   task_data->e.set();
}

该示例产生下面的输出:

1: 250000000
2: 250000000
1: 500000000
2: 500000000
1: 750000000
2: 750000000
1: 1000000000
2: 1000000000

Context::Yield 方法仅让步于当前线程所属的计划程序上的另一个活动线程、轻量级任务或另一个操作系统线程。 此方法不会对 concurrency::task_groupconcurrency::structured_task_group 对象中计划运行但尚未开始的工作做出让步。

还可以通过其他方式在长时间运行的任务之间实现协作。 可将大任务分解为较小的子任务。 还可以在长时间运行任务期间启用过度订阅。 可以通过过度订阅创建比可用硬件线程数更多的线程。 当长时间运行的任务存在严重的延迟时(例如,从磁盘或网络连接读取数据),过度订阅特别有用。 有关轻量级任务和过度订阅的详细信息,请参阅任务计划程序

[返回页首]

使用过度订阅来抵消阻塞或高延迟的操作

并发运行时可提供同步基元(如 concurrency::critical_section),以便任务能够以协作方式停滞和互相做出让步。 如果一个任务以协作方式阻塞或让步,当第一个任务等待数据时,任务计划程序可将处理资源重新分配到另一个上下文。

在某些情况下,无法使用并发运行时提供的协作阻塞机制。 例如,使用的外部库可能使用不同的同步机制。 另一个示例是执行可能存在严重延迟的操作,例如,使用 Windows API ReadFile 函数从网络连接读取数据时。 在这种情况下,过度订阅能使其他任务在另一个任务空闲时运行。 可以通过过度订阅创建比可用硬件线程数更多的线程。

考虑以下 download 函数,它从给定的 URL 下载文件。 此示例使用 concurrency::Context::Oversubscribe 方法临时增加活动线程的数量。

// Downloads the file at the given URL.
string download(const string& url)
{
   // Enable oversubscription.
   Context::Oversubscribe(true);

   // Download the file.
   string content = GetHttpFile(_session, url.c_str());
   
   // Disable oversubscription.
   Context::Oversubscribe(false);

   return content;
}

由于 GetHttpFile 函数执行可能存在延迟的操作,过度订阅能使其他任务在当前任务等待数据时运行。 有关此示例的完整版本,请参阅如何:使用过度订阅来抵消延迟

[返回页首]

尽可能使用并发内存管理函数

如果你具有经常分配生存期相对较短的小型对象的精细任务时,可使用内存管理函数 concurrency::Allocconcurrency::Free。 并发运行时为每个正在运行的线程保留单独的内存缓存。 AllocFree 函数从这些缓存中分配和释放内存,而不使用锁或内存屏障。

有关这些内存管理函数的详细信息,请参阅任务计划程序。 有关使用这些函数的示例,请参阅如何:使用 Alloc 和 Free 提高内存性能

[返回页首]

使用 RAII 管理并发对象的生存期

并发运行时使用异常处理来实现取消等功能。 因此,在调用运行时,或调用另一个调用运行时的库时,请编写异常安全的代码。

“资源获取即初始化”(RAII) 模式是在给定范围内安全管理并发对象生存期的一种方式。 在 RAII 模式下,数据结构在堆栈上分配。 该数据结构在创建资源时初始化或获取资源,并在销毁数据结构时销毁或释放该资源。 RAII 模式保证在封闭范围退出之前调用析构函数。 当函数包含多个 return 语句时,此模式非常有用。 此模式还可帮助你编写异常安全的代码。 当 throw 语句导致堆栈展开时,会调用 RAII 对象的析构函数;因此,始终可以正确删除或释放资源。

运行时定义了几种使用 RAII 模式的类,例如 concurrency::critical_section::scoped_lockconcurrency::reader_writer_lock::scoped_lock。 这些帮助程序类称为“作用域锁”。 在使用 concurrency::critical_sectionconcurrency::reader_writer_lock 对象时,这些类可提供诸多好处。 这些类的构造函数获取对所提供的 critical_sectionreader_writer_lock 对象的访问权限;析构函数释放对该对象的访问权限。 由于有范围的锁在被销毁时会自动释放对其互相排斥对象的访问权限,因此你不需要手动解锁基础对象。

请考虑以下 account 类,它由外部库定义,因此无法修改。

// account.h
#pragma once
#include <exception>
#include <sstream>

// Represents a bank account.
class account
{
public:
   explicit account(int initial_balance = 0)
      : _balance(initial_balance)
   {
   }

   // Retrieves the current balance.
   int balance() const
   {
      return _balance;
   }

   // Deposits the specified amount into the account.
   int deposit(int amount)
   {
      _balance += amount;
      return _balance;
   }

   // Withdraws the specified amount from the account.
   int withdraw(int amount)
   {
      if (_balance < 0)
      {
         std::stringstream ss;
         ss << "negative balance: " << _balance << std::endl;
         throw std::exception((ss.str().c_str()));
      }

      _balance -= amount;
      return _balance;
   }

private:
   // The current balance.
   int _balance;
};

以下示例对 account 对象并行执行多个事务。 该示例使用 critical_section 对象来同步对 account 对象的访问,因为 account 类不是并发安全的。 每个并行操作使用一个 critical_section::scoped_lock 对象来保证在操作成功或失败时解锁 critical_section 对象。 当帐户余额为负时,withdraw 操作会引发异常并失败。

// account-transactions.cpp
// compile with: /EHsc
#include "account.h"
#include <ppl.h>
#include <iostream>
#include <sstream>

using namespace concurrency;
using namespace std;

int wmain()
{
   // Create an account that has an initial balance of 1924.
   account acc(1924);

   // Synchronizes access to the account object because the account class is 
   // not concurrency-safe.
   critical_section cs;

   // Perform multiple transactions on the account in parallel.   
   try
   {
      parallel_invoke(
         [&acc, &cs] {
            critical_section::scoped_lock lock(cs);
            wcout << L"Balance before deposit: " << acc.balance() << endl;
            acc.deposit(1000);
            wcout << L"Balance after deposit: " << acc.balance() << endl;
         },
         [&acc, &cs] {
            critical_section::scoped_lock lock(cs);
            wcout << L"Balance before withdrawal: " << acc.balance() << endl;
            acc.withdraw(50);
            wcout << L"Balance after withdrawal: " << acc.balance() << endl;
         },
         [&acc, &cs] {
            critical_section::scoped_lock lock(cs);
            wcout << L"Balance before withdrawal: " << acc.balance() << endl;
            acc.withdraw(3000);
            wcout << L"Balance after withdrawal: " << acc.balance() << endl;
         }
      );
   }
   catch (const exception& e)
   {
      wcout << L"Error details:" << endl << L"\t" << e.what() << endl;
   }
}

此示例产生以下示例输出:

Balance before deposit: 1924
Balance after deposit: 2924
Balance before withdrawal: 2924
Balance after withdrawal: -76
Balance before withdrawal: -76
Error details:
    negative balance: -76

有关使用 RAII 模式管理并发对象生存期的更多示例,请参阅演练:从用户界面线程中删除工作如何:使用上下文类实现协作信号灯以及如何:使用过度订阅来抵消延迟

[返回页首]

不要在全局范围创建并发对象

在全局范围内创建并发对象时,会导致应用程序中出现死锁或内存访问冲突等问题。

例如,在创建并发运行时对象时,如果尚未创建计划程序,运行时会创建一个默认计划程序。 相应地,在全局对象构造期间创建的运行时对象将导致运行时创建此默认计划程序。 但是,此过程采用了内部锁,这会干扰支持并发运行时基础结构的其他对象的初始化过程。 另一个尚未初始化的基础结构对象可能需要此内部锁,因此会导致您的应用程序中发生死锁。

以下示例演示如何创建全局 concurrency::Scheduler 对象。 此模式不仅适用于 Scheduler 类,还适用于并发运行时提供的所有其他类型。 建议您不要遵循此模式,因为它可能会导致您的应用程序中出现意外行为。

// global-scheduler.cpp
// compile with: /EHsc
#include <concrt.h>

using namespace concurrency;

static_assert(false, "This example illustrates a non-recommended practice.");

// Create a Scheduler object at global scope.
// BUG: This practice is not recommended because it can cause deadlock.
Scheduler* globalScheduler = Scheduler::Create(SchedulerPolicy(2,
   MinConcurrency, 2, MaxConcurrency, 4));

int wmain() 
{   
}

有关创建 Scheduler 对象的正确方法的示例,请参阅任务计划程序

[返回页首]

不要在共享数据段中使用并发对象

并发运行时不支持在共享数据部分(例如,由 data_seg#pragma 指令创建的数据部分)使用并发对象。 跨进程边界共享的并发对象可能会导致运行时处于不一致或无效状态。

[返回页首]

另请参阅

并发运行时最佳做法
并行模式库 (PPL)
异步代理库
任务计划程序
同步数据结构
将同步数据结构与 Windows API 进行比较
如何:使用 Alloc 和 Free 提高内存性能
如何:使用过度订阅偏移延迟
如何:使用上下文类实现协作信号量
演练:从用户界面线程中删除工作
并行模式库中的最佳做法
异步代理库中的最佳做法