借助 C++ 进行 Windows 开发

线程池环境

Kenny Kerr

Kenny Kerr
组成 Windows 线程池 API 的对象可被分为两大阵营。第一阵营中的是那些表示工作、计时器、I/O 和可等待的对象。这些对象都有可能导致在线程池上执行回调。我已经在上个月的专栏中介绍过工作对象,并将在后续文章中探讨余下的对象。第二阵营中的是那些用来控制这些回调的执行环境的对象。这正是本月专栏的重点讨论内容。

线程池环境会影响回调是在默认池中执行,还是在您自己生成的特定池中执行,以及回调是否应该优先处理等等。随着您使用的工作对象或回调越来越多,能够控制这些环境就变得越来越重要。它还降低了协调这些对象的取消和拆解操作的复杂程度(下月专栏的主题)。

与其他组成线程池 API 的对象相比,线程池环境并不是相同意义上的对象。为了实现效率,它直接声明为一个结构,这样一来,您就可以直接在应用程序中为其分配存储。然而,您应该按照处理其他对象的方式来处理它,不要假设您了解其内部信息,而是只通过一组公开的 API 函数来访问它。该结构名为 TP_CALLBACK_ENVIRON,如果您继续了解它,立刻就会注意到:自 Windows Vista 首次引入它以来,它已经有过改变。这再一次提醒您必须使用 API 函数。函数本身只是去操控此结构,但防止您做出任何改动。它们通过内嵌声明,允许编译器尽可能将其优化,所以不要想象您可以做得更好。

InitializeThreadpoolEnvironment 函数使用默认设置来准备该结构。DestroyThreadpoolEnvironment 函数则释放该环境所使用的任何资源。到撰写本文时为止,它并未执行任何操作;但在将来可能会有所不同。因为它是一个内联函数,在编译时就会被去除,所以调用它不会带来危害。图 1 显示了包装它的一个类。

图 1 包装 InitializeThreadpoolEnvironment 函数

class environment
{
  environment(environment const &);
  environment & operator=(environment const &);
 
  TP_CALLBACK_ENVIRON m_value;
 
public:
 
  environment() throw()
  {
    InitializeThreadpoolEnvironment(&m_value);
  }
 
  ~environment() throw()
  {
    DestroyThreadpoolEnvironment(&m_value);
  }
 
  PTP_CALLBACK_ENVIRON get() throw()
  {
    return &m_value;
  }
};

为了与我在 2011 年 7 月的专栏 (https://msdn.microsoft.com/magazine/hh288076) 中介绍的 unique_handle 类模板保持一致,我还提供了熟悉的 get 成员函数。 有心的读者可能会想起我在上个月的专栏中介绍的 CreateThreadpoolWork 和 TrySubmitThreadpoolCallback 函数还有最后一个参数没有提到。 在每个示例中,我都只是传递了一个 null 指针值。 该参数实际上是一个指向环境的指针,您正是通过该参数将不同的工作对象关联到环境的:

environment e;
work w(CreateThreadpoolWork(callback, nullptr, e.get()));
check_bool(w);

这有什么好处? 其实不怎么样,直到您开始自定义环境仍然如此。

私有池

默认情况下,环境会将回调引导到进程的默认线程池。 如果您没有将工作与环境相关联,同一线程池就会处理该回调。 请考虑为进程设置默认线程池的含义。 在进程内运行的任何代码均可使用此线程池。 请记住,每个进程平均会直接或间接加载数十个 DLL。 很明显,这会严重影响性能。 但这并不一定是坏事。 由进程中的各个子系统共享一个线程池通常可以提高性能,因为这样可以高效地共享计算机中数量有限的实际处理器。

另一种方法就是每个子系统创建属于自己的线程池,以更加不合作的方式去争用处理器周期。 另一方面,如果个别子系统滥用默认线程池,则可以通过使用私有池来避免发生这种情况。 另一子系统可能会在队列中排入长时间运行的回调,或者回调的响应时间不可接受的大量回调。 您可能还会有一些特殊要求,必须对池中的线程施加某些限制。 池对象就在此引入。

CreateThreadpool 函数可创建完全独立于默认线程池的私有池对象。 如果该函数成功,会返回一个不透明指针来表示池对象。 如果失败,会返回一个 null 指针值,并通过 GetLastError 函数提供更多信息。 给定池对象后,CloseThreadpool 函数指示系统该对象可释放。 此外,我在 2011 年 7 月专栏中介绍的 unique_handle 类模板在池专用特征类的帮助下可以对这些细节进行处理:

struct pool_traits
{
  static PTP_POOL invalid() throw()
  {
    return nullptr;
  }
 
  static void close(PTP_POOL value) throw()
  {
    CloseThreadpool(value);
   }
};
 
typedef unique_handle<PTP_POOL, pool_traits> pool;

现在,我可以使用方便的 typedef 并创建一个池对象,如下所示:

pool p(CreateThreadpool(nullptr));
check_bool(p);

此时此刻,我没有隐藏任何东西。 本例中的参数留待将来使用,而且必须设置为 null 指针值。 SetThreadpoolCallbackPool 内联函数会更新环境,指明应该将回调引导到哪个池:

SetThreadpoolCallbackPool(e.get(), p.get());

这样,在此环境中创建的工作对象和其他任何对象都将与给定的池关联起来。 您甚至可以创建一些不同的环境,每个环境都带有自己的池,以便隔离应用程序的不同部分。 需要注意平衡不同池之间的并发,所以不要引入拥有过多线程的过度安排。

正如我之前暗示的,可以为您的池中的线程数量设置最小值和最大值限制。 不允许使用这种方式控制默认线程池,因为这会影响其他子系统并引发各种兼容性问题。 例如,我可能创建一个刚好带有一个线程的池来处理具有线程关联的 API,并创建另一个池用于完成 I/O 和其他相关回调,该池没有任何限制,允许系统根据需要动态调整线程的数量。 这里是我如何设置一个池,使其刚好分配一个持久线程:

check_bool(SetThreadpoolThreadMinimum(p.get(), 1));
SetThreadpoolThreadMaximum(p.get(), 1);

请注意,设置最小值可能会失败,而设置最大值不会失败。 默认最小值为零,设置为其他值就会失败,这是由于它实际上会根据需要创建尽可能多的线程。

确定回调优先级

线程池环境的另一功能就是能够确定回调优先级。 这是 Windows 7 中的 Windows 线程池 API 唯一新增的功能。 如果您仍然针对 Windows Vista 系统,请牢记这一点。 优先回调可以保证在任何优先级较低的回调之前执行。 这并不会影响线程的优先级,因此不会引起正在执行的回调被抢占。 优先回调只会影响正在等待执行的回调的顺序。

共有三个优先级:低、中和高。 SetThreadpoolCallbackPriority 函数设置环境的优先级:

SetThreadpoolCallbackPriority(e.get(), TP_CALLBACK_PRIORITY_HIGH);

同样,在此环境中创建的工作对象和其他任何对象都将相应地确定它们的回调优先级。

串行池

我在上个月的专栏中介绍了 functional_pool 示例类,用来演示与工作对象相关的各种函数。 这次,我将展示如何利用我在本月介绍的处理线程池环境的所有函数来构建一个简单的优先串行池。 说到串行,我的意思是希望这个池恰好管理一个持久线程。 说到优先,我将支持以中或高优先级来提交函数。 我可以继续开始定义 serial_pool 类,如图 2 所示。

图 2 定义 Serial_Pool 类

class serial_pool
{
  typedef concurrent_queue<function<void()>> queue;
 
  pool m_pool;
  queue m_queue, m_queue_high;
  work m_work, m_work_high;
 
  static void CALLBACK callback(
    PTP_CALLBACK_INSTANCE, void * context, PTP_WORK)
  {
    auto q = static_cast<queue *>(context);
 
    function<void()> function;
    q->try_pop(function);
 
    function();
  }

与 functional_pool 类不同,serial_pool 真正管理一个池对象。 它同样需要将队列和工作对象分为中和高优先级。 工作对象可以使用不同的上下文值来创建,使其指向各个队列,然后重复使用私有回调函数。 这将在我的部件运行时避免任何分支。 回调仍然只是从队列中弹出一个函数并调用它。 然而,serial_pool 构造函数(如图 3 所示)还需要再做一些工作。

图 3 Serial_Pool 构造函数

public:
 
  serial_pool() :
    m_pool(CreateThreadpool(nullptr))
  {
    check_bool(m_pool);
    check_bool(SetThreadpoolThreadMinimum(m_pool.get(), 1));
    SetThreadpoolThreadMaximum(m_pool.get(), 1);
 
    environment e;
    SetThreadpoolCallbackPool(e.get(), m_pool.get());
 
    SetThreadpoolCallbackPriority(e.get(), TP_CALLBACK_PRIORITY_NORMAL);
    check_bool(m_work.reset(CreateThreadpoolWork(
      callback, &m_queue, e.get())));
 
    SetThreadpoolCallbackPriority(e.get(), TP_CALLBACK_PRIORITY_HIGH);
    check_bool(m_work_high.reset(CreateThreadpoolWork(
      callback, &m_queue_high, e.get())));
  }

首先,创建私有池并为其设置并发限制,以确保串行执行任何回调。 其次,它创建一个环境并设置供后续对象采用的池。 最后,它创建工作对象,调整环境的优先级来建立工作对象各自的优先级,并连接到它们共享的私有池。 尽管池和工作对象需要保持 serial_pool 对象的整个生存期,环境仍会在堆栈上创建,因为它只需在相关各方之间建立联系。

现在,析构函数需要等待两个工作对象来确保 serial_pool 对象被销毁后没有执行任何回调:

~serial_pool()
{
  WaitForThreadpoolWorkCallbacks(m_work.get(), true);
  WaitForThreadpoolWorkCallbacks(
    m_work_high.get(), true);
}

最后,两个 submit 函数必须将函数以中或高优先级排入队列:

template <typename Function>
void submit(Function const & function)
{
  m_queue.push(function);
  SubmitThreadpoolWork(m_work.get());
}
 
template <typename Function>
void submit_high(Function const & function)
{
  m_queue_high.push(function);
  SubmitThreadpoolWork(m_work_high.get());
}

其实,这完全取决于工作对象的创建方式,尤其是提供了关于所需线程池环境的哪些信息。 图 4 展示了您可以使用的简单示例,您可以在其中清楚看到实际的串行和优先行为。

图 4 实际的串行和优先行为

int main()
{
  serial_pool pool;
 
  for (int i = 0; i < 10; ++i)
  {
    pool.submit([]
    {
      printf("normal: %d\n", GetCurrentThreadId());
    });
 
    pool.submit_high([]
    {
      printf("high: %d\n", GetCurrentThreadId());
    });
  }
  getch();
}

图 4 所示的示例中,有可能先执行中优先级的回调(因为它是第一个提交的),具体取决于系统响应的速度。 然后,应执行所有高优先级的回调,再执行其余的正常优先级回调。 您可以通过以下方式来做实验:添加 Sleep 调用并逐步提高并发水平,以查看线程池如何根据您的规定来调整其行为。

下个月请和我一起探索由 Windows 线程池 API 提供的关键取消和清除功能。

Kenny Kerr 是一位热衷于本机 Windows 开发的软件专家。您可以通过 http://kennykerr.ca 与他联系。

衷心感谢以下技术专家对本文进行了审阅:Stephan T. Lavavej