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

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

各节内容

本文档包含以下几节:

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

  • 避免不让步的长任务

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

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

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

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

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

尽可能使用协作同步构造

并发运行时提供了许多无需外部同步对象的并发安全的构造。例如, concurrency::concurrent_vector 类提供并发安全追加的访问操作的元素。但是,对于需要对资源的独占访问的情况下,运行库提供 concurrency::critical_sectionconcurrency::reader_writer_lock,和 concurrency::event 类。这些类型以协作方式工作;因此,当第一个任务等待数据时,任务计划程序可以将处理资源重新分配给另一个上下文。请尽可能使用这些同步类型而不是其他同步机制,例如 Windows API(它不以协作方式工作)提供的同步机制。有关这些同步类型和代码示例的更多信息,请参见同步数据结构将同步数据结构与 Windows API 进行比较

Top

避免不让步的长任务

因为任务计划程序以协作方式工作,所以它无法保证任务之间的公平性。因此,一项任务可以阻止其他任务启动。虽然这在某些情况下是可以接受的,但在其他情况下可能导致死锁或匮乏。

以下示例可执行比已分配的处理资源数更多的任务。第一个任务不对任务计划程序让步,因此在完成第一个任务之前,第二个任务不会启动。

// 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 对象,但尚未开始。

还可以通过其他方式在长期运行的任务之间实现协作。您可以将大任务划分为较小的子任务,还可以在执行长期任务期间启用过度订阅。您可以通过过度订阅创建比可用硬件线程数更多的线程。如果长期任务包含大量延迟(例如,从磁盘或网络连接读取数据),则过度订阅会很有用。有关轻量级任务和过度订阅的更多信息,请参见任务计划程序(并发运行时)

Top

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

并发运行时提供同步元语,如 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 函数执行潜在延迟操作,所以在当前任务等待数据时,过度订阅可以使其他任务能够运行。有关此示例的完整版本,请参见如何:使用过度订阅抵消延迟

Top

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

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

有关这些内存管理函数的更多信息,请参见任务计划程序(并发运行时)。有关使用这些函数的示例,请参见如何:使用 Alloc 和 Free 提高内存性能

Top

使用 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 模式管理并发对象生存期的其他示例,请参见演练:从用户界面线程中移除工作如何:使用上下文类实现协作信号量如何:使用过度订阅抵消延迟

Top

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

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

例如,在创建并发运行时对象时,运行时创建的缺省调度程序为您如果其中一个还没有创建。在全局对象构造过程中创建的运行时对象相应地将导致运行时创建此缺省调度程序。但是,此过程需要的内部锁,可能会干扰其他支持的并发运行时基础结构的对象的初始化。此内部锁可能会要求有尚未初始化,并因此可能导致死锁发生在您的应用程序中的另一个基础架构对象。

下面的示例演示如何创建全局 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 对象的正确方式的示例,请参见任务计划程序(并发运行时)

Top

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

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

Top

请参见

任务

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

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

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

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

概念

并行模式库 (PPL)

异步代理库

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

同步数据结构

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

并行模式库中的最佳做法

异步代理库中的最佳做法

其他资源

并发运行时最佳做法