std::thread 和 tbb::task_group 之间的线程 ID 重用导致 OpenMP 中的死锁
Thread ID reuse between std::thread and tbb::task_group causing deadlock in OpenMP
*** 更新:将代码更改为重现问题的真实案例 ***
我正在使用一些使用了多种多线程技术的现有代码; std::thread,加上英特尔 TBB 任务组,再加上 OpenMP。看起来我在 std::thread 加入中遇到了竞争条件,并且可能在 OpenMP 中也遇到了竞争条件。 (但是当然那些库是由聪明人编写的,所以如果我正在使用的代码中有错误,我希望你能帮我解决。)
场景是主线程启动一堆I/O个worker std::thread,它们自己发起一些任务,任务有一些代码段使用OpenMP进行并行。主线程执行 std::thread::join() 等待 std::threads,然后执行 tbb::TaskGroup::wait() 等待任务完成。
#include <Windows.h>
#include <tbb/task_group.h>
#include <tbb/concurrent_vector.h>
#include <iostream>
#include <sstream>
#include <thread>
void DoCPUIntensiveWork(int chunkIndex);
int main ()
{
unsigned int hardwareConcurrency = 64;
tbb::concurrent_vector<std::shared_ptr<std::thread>> ioThreads;
tbb::task_group taskGroup;
wprintf(L"Starting %u IO threads\n", hardwareConcurrency);
for (unsigned int cx = 0; cx < hardwareConcurrency; ++cx)
{
ioThreads.push_back(std::shared_ptr<std::thread>(new std::thread([&taskGroup, cx]
{
wprintf(L"IO thread %u starting\r\n", GetCurrentThreadId());
// Not doing any actual IO
taskGroup.run([cx]
{
wprintf(L"CPU task %u starting\r\n", GetCurrentThreadId());
DoCPUIntensiveWork(cx);
wprintf(L"CPU task %u done\r\n", GetCurrentThreadId());
});
//Sleep(1000); Un-commenting this will make the program terminate
wprintf(L"IO thread %u done\r\n", GetCurrentThreadId());
})));
}
// Join the IO workers
for (std::shared_ptr<std::thread>& thread : ioThreads)
{
std::stringstream ss;
ss << thread->get_id();
wprintf(L"Wait for thread %S\r\n", ss.str().c_str());
thread->join(); // main thread hangs here
}
wprintf(L"IO work complete\n");
// And then wait for the CPU tasks
taskGroup.wait();
wprintf(L"CPU work complete\n");
return 0;
}
并且 CPU- 密集型工作包括 OpenMP 的使用。 (注意,如果我删除计划(静态),结果是相同的。)
// Note: I shrunk these numbers down until the amount of work is actually
// small, not CPU-intensive at all, and it still hangs
static const int GlobalBufferChunkSize = 64;
static const int NumGlobalBufferChunks = 64;
static const int StrideSize = 16;
static const int OverwriteCount = 4;
BYTE GlobalBuffer[NumGlobalBufferChunks * GlobalBufferChunkSize];
void DoCPUIntensiveWork(int chunkIndex)
{
BYTE* pChunk = GlobalBuffer + (chunkIndex * GlobalBufferChunkSize);
#pragma omp parallel for schedule(static)
for (int i = 0; i < (GlobalBufferChunkSize / StrideSize); i++)
{
BYTE* pStride = pChunk + (i * StrideSize);
for (int j = 0; j < OverwriteCount; j++)
{
memset(pStride, i, StrideSize);
}
} // Task thread hangs here
}
此代码挂起;主线程永远等待 thread->join() 。即使在只有一个 IO 作业/CPU 密集型任务的测试用例上。我添加了您在上面看到的 printf,结果显示 IO 作业快速完成,该线程退出,然后 CPU 密集型任务以 相同的线程 ID 在主线程甚至进入 join() 调用之前。
Starting 64 IO threads
IO thread 3708 starting
IO thread 23728 starting
IO thread 23352 starting
IO thread 3588 starting
IO thread 3708 done
IO thread 23352 done
IO thread 22080 starting
IO thread 23728 done
IO thread 3376 starting
IO thread 3588 done
IO thread 27436 starting
IO thread 10092 starting
IO thread 22080 done
IO thread 10480 starting
CPU task 3708 starting
IO thread 3376 done
IO thread 27436 done
IO thread 10092 done
IO thread 10480 done
Wait for thread 3708
... hang forever ...
线程完成后,IO 线程 ID 被重用于任务,thread->join() 调用仍然坐在那里等待。当我查看调试器时,thread->join() 正在等待 ID 为 3708 的线程,并且确实存在具有该 ID 的线程,但该线程正在执行 the task 而不是IO 工作。所以看起来进程的主线程实际上是在等待任务而不是等待IO线程,因为ID重用。 (我找不到文档或代码来查看 std::thread::join() 是否基于 ID 或句柄等待,但它似乎使用了 ID,这将是一个错误。)
第二个有趣的事情是,该任务永远不会完成,当我在调试器中查看正在执行该任务的线程时,它位于 OpenMP 并行执行的末尾。我没有看到任何其他线程执行并行工作。 ntdll.dll 代码中有许多来自 vcomp140[d].dll 的线程,我没有符号 - 我认为这些线程只是在等待新工作,而不是执行我的任务。 CPU 为 0%。我非常有信心没有人在循环。所以,TBB任务挂在了OpenMP多线程实现的某个地方。
但是,为了让生活变得复杂,任务似乎不会挂起,除非来自 IO 线程的线程 ID 恰好被任务重用。因此,在 std::thread 和 TBB 任务以及 OpenMP 并行性之间的某处存在由线程 ID 重用触发的竞争条件。
我发现了两个解决挂起问题的方法:
- 在 IO 线程的末尾放置一个 Sleep(1000),这样 IO 线程 ID 就不会被任务重用。当然,这个错误仍然存在,等待时机不对。
- 删除 OpenMP 并行性的使用。
一位同事提出了第三种可能的选择,用 TBB 代替 OpenMP 并行性 parallel_for。我们可能会那样做。当然,这是我们希望尽可能少接触的来自不同来源的所有代码层。
我将此报告为可能的错误报告,而不是寻求帮助。
- 如果重复使用线程 ID,std::thread::join() 可能最终会等待错误的线程,这似乎是一个错误。它应该通过句柄等待,而不是通过 ID 等待。
- TBB 任务和 OpenMP 之间似乎存在错误或不兼容,如果 OpenMP 主线程在 运行 恰好具有重复使用的线程 ID 的 TBB 任务上,它可能会挂起。
更新:关于超额认购的假设是不正确的。参见 https://github.com/oneapi-src/oneTBB/issues/353
我认为问题可能是由 OpenMP 语义引起的。默认情况下,它总是创建与硬件并发一样多的线程。
TBB 将创建 std::thread::hardware_concurrency()
个线程,OpenMP 将为调用它的每个 TBB 工作线程创建 std::thread::hardware_concurrency()
个。 IE。在示例中,我们将有多达 std::thread::hardware_concurrency()*std::thread::hardware_concurrency()
个线程(+64 个 IO 线程)。如果机器比较大,例如超过 32 个线程,应用程序中将有 32*32 = 1024
个线程(总的来说,它接近默认 ulimit 还是 Windows?)无论如何,如此大的超额订阅与 OpenMP 屏障语义结束并行区域会导致非常长的执行时间(例如几分钟甚至几小时)。
为什么 Sleep(1000)
有帮助?我不确定,但它可能会为系统提供一些 CPU 资源以推进。
要检查这个想法,请将 num_threads(1)
子句添加到 #pragma omp parallel for num_threads(1)
以限制 OpenMP 运行时创建的线程数。
将此问题移至 TBB GitHub,位于 https://github.com/oneapi-src/oneTBB/issues/353
*** 更新:将代码更改为重现问题的真实案例 ***
我正在使用一些使用了多种多线程技术的现有代码; std::thread,加上英特尔 TBB 任务组,再加上 OpenMP。看起来我在 std::thread 加入中遇到了竞争条件,并且可能在 OpenMP 中也遇到了竞争条件。 (但是当然那些库是由聪明人编写的,所以如果我正在使用的代码中有错误,我希望你能帮我解决。)
场景是主线程启动一堆I/O个worker std::thread,它们自己发起一些任务,任务有一些代码段使用OpenMP进行并行。主线程执行 std::thread::join() 等待 std::threads,然后执行 tbb::TaskGroup::wait() 等待任务完成。
#include <Windows.h>
#include <tbb/task_group.h>
#include <tbb/concurrent_vector.h>
#include <iostream>
#include <sstream>
#include <thread>
void DoCPUIntensiveWork(int chunkIndex);
int main ()
{
unsigned int hardwareConcurrency = 64;
tbb::concurrent_vector<std::shared_ptr<std::thread>> ioThreads;
tbb::task_group taskGroup;
wprintf(L"Starting %u IO threads\n", hardwareConcurrency);
for (unsigned int cx = 0; cx < hardwareConcurrency; ++cx)
{
ioThreads.push_back(std::shared_ptr<std::thread>(new std::thread([&taskGroup, cx]
{
wprintf(L"IO thread %u starting\r\n", GetCurrentThreadId());
// Not doing any actual IO
taskGroup.run([cx]
{
wprintf(L"CPU task %u starting\r\n", GetCurrentThreadId());
DoCPUIntensiveWork(cx);
wprintf(L"CPU task %u done\r\n", GetCurrentThreadId());
});
//Sleep(1000); Un-commenting this will make the program terminate
wprintf(L"IO thread %u done\r\n", GetCurrentThreadId());
})));
}
// Join the IO workers
for (std::shared_ptr<std::thread>& thread : ioThreads)
{
std::stringstream ss;
ss << thread->get_id();
wprintf(L"Wait for thread %S\r\n", ss.str().c_str());
thread->join(); // main thread hangs here
}
wprintf(L"IO work complete\n");
// And then wait for the CPU tasks
taskGroup.wait();
wprintf(L"CPU work complete\n");
return 0;
}
并且 CPU- 密集型工作包括 OpenMP 的使用。 (注意,如果我删除计划(静态),结果是相同的。)
// Note: I shrunk these numbers down until the amount of work is actually
// small, not CPU-intensive at all, and it still hangs
static const int GlobalBufferChunkSize = 64;
static const int NumGlobalBufferChunks = 64;
static const int StrideSize = 16;
static const int OverwriteCount = 4;
BYTE GlobalBuffer[NumGlobalBufferChunks * GlobalBufferChunkSize];
void DoCPUIntensiveWork(int chunkIndex)
{
BYTE* pChunk = GlobalBuffer + (chunkIndex * GlobalBufferChunkSize);
#pragma omp parallel for schedule(static)
for (int i = 0; i < (GlobalBufferChunkSize / StrideSize); i++)
{
BYTE* pStride = pChunk + (i * StrideSize);
for (int j = 0; j < OverwriteCount; j++)
{
memset(pStride, i, StrideSize);
}
} // Task thread hangs here
}
此代码挂起;主线程永远等待 thread->join() 。即使在只有一个 IO 作业/CPU 密集型任务的测试用例上。我添加了您在上面看到的 printf,结果显示 IO 作业快速完成,该线程退出,然后 CPU 密集型任务以 相同的线程 ID 在主线程甚至进入 join() 调用之前。
Starting 64 IO threads
IO thread 3708 starting
IO thread 23728 starting
IO thread 23352 starting
IO thread 3588 starting
IO thread 3708 done
IO thread 23352 done
IO thread 22080 starting
IO thread 23728 done
IO thread 3376 starting
IO thread 3588 done
IO thread 27436 starting
IO thread 10092 starting
IO thread 22080 done
IO thread 10480 starting
CPU task 3708 starting
IO thread 3376 done
IO thread 27436 done
IO thread 10092 done
IO thread 10480 done
Wait for thread 3708
... hang forever ...
线程完成后,IO 线程 ID 被重用于任务,thread->join() 调用仍然坐在那里等待。当我查看调试器时,thread->join() 正在等待 ID 为 3708 的线程,并且确实存在具有该 ID 的线程,但该线程正在执行 the task 而不是IO 工作。所以看起来进程的主线程实际上是在等待任务而不是等待IO线程,因为ID重用。 (我找不到文档或代码来查看 std::thread::join() 是否基于 ID 或句柄等待,但它似乎使用了 ID,这将是一个错误。)
第二个有趣的事情是,该任务永远不会完成,当我在调试器中查看正在执行该任务的线程时,它位于 OpenMP 并行执行的末尾。我没有看到任何其他线程执行并行工作。 ntdll.dll 代码中有许多来自 vcomp140[d].dll 的线程,我没有符号 - 我认为这些线程只是在等待新工作,而不是执行我的任务。 CPU 为 0%。我非常有信心没有人在循环。所以,TBB任务挂在了OpenMP多线程实现的某个地方。
但是,为了让生活变得复杂,任务似乎不会挂起,除非来自 IO 线程的线程 ID 恰好被任务重用。因此,在 std::thread 和 TBB 任务以及 OpenMP 并行性之间的某处存在由线程 ID 重用触发的竞争条件。
我发现了两个解决挂起问题的方法:
- 在 IO 线程的末尾放置一个 Sleep(1000),这样 IO 线程 ID 就不会被任务重用。当然,这个错误仍然存在,等待时机不对。
- 删除 OpenMP 并行性的使用。
一位同事提出了第三种可能的选择,用 TBB 代替 OpenMP 并行性 parallel_for。我们可能会那样做。当然,这是我们希望尽可能少接触的来自不同来源的所有代码层。
我将此报告为可能的错误报告,而不是寻求帮助。
- 如果重复使用线程 ID,std::thread::join() 可能最终会等待错误的线程,这似乎是一个错误。它应该通过句柄等待,而不是通过 ID 等待。
- TBB 任务和 OpenMP 之间似乎存在错误或不兼容,如果 OpenMP 主线程在 运行 恰好具有重复使用的线程 ID 的 TBB 任务上,它可能会挂起。
更新:关于超额认购的假设是不正确的。参见 https://github.com/oneapi-src/oneTBB/issues/353
我认为问题可能是由 OpenMP 语义引起的。默认情况下,它总是创建与硬件并发一样多的线程。
TBB 将创建 std::thread::hardware_concurrency()
个线程,OpenMP 将为调用它的每个 TBB 工作线程创建 std::thread::hardware_concurrency()
个。 IE。在示例中,我们将有多达 std::thread::hardware_concurrency()*std::thread::hardware_concurrency()
个线程(+64 个 IO 线程)。如果机器比较大,例如超过 32 个线程,应用程序中将有 32*32 = 1024
个线程(总的来说,它接近默认 ulimit 还是 Windows?)无论如何,如此大的超额订阅与 OpenMP 屏障语义结束并行区域会导致非常长的执行时间(例如几分钟甚至几小时)。
为什么 Sleep(1000)
有帮助?我不确定,但它可能会为系统提供一些 CPU 资源以推进。
要检查这个想法,请将 num_threads(1)
子句添加到 #pragma omp parallel for num_threads(1)
以限制 OpenMP 运行时创建的线程数。
将此问题移至 TBB GitHub,位于 https://github.com/oneapi-src/oneTBB/issues/353