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

更新:2011 年 3 月

本文档描述应用于并发运行时的多个领域的最佳实践。

各节内容

本文档包含以下几节:

  • 尽可能使用协作同步构造

  • 避免不让步的长任务

  • 使用过度订阅抵消停滞或高延迟的操作

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

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

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

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

尽可能使用协作同步构造

并发运行时提供了许多无需外部同步对象的并发安全的构造。 例如,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 指令创建的数据段)中使用并发对象。 跨进程边界共享的并发对象可使运行时处于不一致或无效的状态。

[转到页首]

请参见

任务

如何:使用 Alloc 和 Free 提高内存性能

如何:使用过度订阅抵消延迟

演练:从用户界面线程中移除工作

概念

并发运行时最佳做法

并行模式库 (PPL)

异步代理库

任务计划程序(并发运行时)

同步数据结构

将同步数据结构与 Windows API 进行比较

其他资源

如何:使用上下文类实现协作信号量

并行模式库中的最佳做法

异步代理库中的最佳做法

修订记录

日期

修订记录

原因

2011 年 3 月

添加了有关可能在全局范围内发生死锁的信息。

客户反馈