无锁、基于线程的日志缓冲区实现,用于调试高性能多线程应用程序





0/5 (0投票)
无锁、基于线程的日志缓冲区实现的思路和简要实现细节,用于调试高性能多线程应用程序
引言
我想介绍一种在多线程应用程序中实现无锁、基于线程的日志缓冲区插装的思路和实现。如果您有任何意见,请在评论中提出。
背景
当多个线程在单个进程中工作时,会发生某些情况,我们称之为竞态条件。在竞态条件下,执行结果是不可预测的,最重要的是,这些竞态条件总是非常不一致的。
由于竞态条件的不一致性,调试变得更加困难和具有挑战性。
处理这种情况最传统的方法是将相关详细信息记录到日志文件中,以便稍后检查,或者进行实时调试。
实时调试 只有在您运气好的时候才可能做到 :-)。大多数情况下,由于竞态条件引起的 bug很难重现,也很难找到根本原因。
在生产环境中出现问题时,工程师几乎没有机会接触到出现问题的现场。
利用现有的机会,无论如何,都必须解决这个难题。
基于我从支持经验中获得的教训,我想分享一种新颖的方法,我们可以用它来记录调试信息,而且适用于高性能、竞态条件。
传统方法的缺点
文件日志 - 这是最简单的方法之一。但文件 I/O 会显著增加延迟。即使是毫秒级的延迟,也会影响竞态条件,导致问题场景根本不会发生。
通用日志结构记录 - 这是一种更常见的开发方法,其中使用一个全局日志缓冲区来记录相关详细信息。然而,这个缓冲区又成为线程之间的共享资源。所以显然需要一个互斥锁来保护共享资源。当引入互斥时,您就破坏了并行性。这又会引入由互斥锁加锁和解锁带来的延迟,从而导致竞态条件根本不发生。
思路与实现
正如您所料,通用日志结构记录的问题在于存在共享的互斥锁。
只要有一个或多个线程正在使用同一资源,就必须有锁。所以我们必须打破这种共享资源。
如果所有工作线程都有自己的缓冲区用于记录,那么它们当前正在做什么,并且进程中有一个全局位置连接所有这些唯一的缓冲区,那么这将正好达到目的。
这就是本文的思路。这可以通过两种方式实现。为传入的线程动态分配缓冲区(因为线程可以动态创建)或者将线程 ID 作为全局缓冲区的索引。虽然第一种选项是最具可伸缩性的选项,但如果附加缓冲区到线程所引入的延迟对您的场景也很关键,那么可以考虑第二种选项。
下图将为您提供该逻辑的概览。
gGLobalBuffers
是一个由线程 ID 索引的日志缓冲区数组。在这里,您可能会问未使用的缓冲区是否会造成浪费。我的观点是,这不会成为产品功能或交付给客户的特性。我们可以做任何事情来解决客户的问题。因此,为了找到根本原因而牺牲一些内存仍然是一个很好的尝试。
每个缓冲区都是一个 THINLOGTHREADCONTAINER
类型,它包含一个 THINLOGBUFFER
类型的日志缓冲区数组以及指向全局缓冲区(globalBuffer)的线程 ID 索引。
同一个线程可能会被多个调用者调用(考虑工作线程)。在这种情况下,如果每个线程只有一个缓冲区,那么同一个线程的下一个调用者将覆盖前一个调用者为同一个线程记录的信息。我们不希望那样发生,对吧?:-)。这就是为单个线程容器标记多个日志缓冲区的原因。
特殊容器 - 线程 0
第一个容器是特殊的。显然,您不会有线程 0。所以它被用作溢出容器。例如,您估计您的应用程序只会拥有 100 个线程。所以 100 个容器可能足够了。但在运行时,另一个线程会出现(例如,客户在运行时更改了配置)。此时线程 0 容器会挺身而出。容器 0 上的数据存在表明您需要重新审视您的资源设置。
Using the Code
步骤 1 - 在 thinLoggerMT.h 的 THINLOGBUFFER struct
中定义您自己的插装信息。
struct thinLogBuffer
{
unsigned long nTestLong;
int nTestInt;
float nTestFloat;
char szTestChar[MAX_CHAR];
unsigned long nClockTicks;
};
typedef struct thinLogBuffer THINLOGBUFFER;
尽量使用基本类型。插装逻辑仅使用 memcpy()
。因此,复制指针不会复制实际的被指向对象。但同样,您可以覆盖 doInstrumentation()
来执行深拷贝。
步骤 2 - 定义您的总容器大小和每个容器的缓冲区数量
#define MAX_BUFFERS_PER_THREAD 5
#define MAX_THREADS_PER_PROCESS 7000
上面的代码片段将创建 7000 个容器(可以处理 ID 在 0-7000 之间的线程),并且每个线程可以同时保存 5 个调用者的信息。
重要提示
在 Solaris 中,线程从线程 1 开始。在 Linux 中,没有线程/lwp 的概念。它们是简单的进程,但具有共享的地址空间。所以在 Linux 中,线程 ID 会根据当时运行的进程数量而不断增加。如果您在 Linux 中使用此解决方案,则应增加容器数量,因为每个线程的 lwp ID 将用作全局缓冲区的索引。您还可以维护一个应用程序特定的逻辑,将短整数映射到 lwp,并将该短整数作为 globalBuffers
的索引。但是,这可能会引入延迟。如果您的应用程序不是那么实时,那么您可以选择这样做。
步骤 3 - 使用 API init() 和 doInstrumentation
使用 init()
初始化库
int nRet = init();
if(nRet < 0)
{
// probably some problem
printf("\n Unable to init the library");
return;
}
创建一个本地 THINLOGBUFFER
,填充值并进行插装。
THINLOGBUFFER myBuffer;
struct timeval tp;
gettimeofday(&tp, NULL);
unsigned long ms = tp.tv_sec * 1000 + tp.tv_usec / 1000;
myBuffer.nClockTicks = ms;
myBuffer.nTestFloat = (676.56 + rand());
myBuffer.nTestInt = rand();
myBuffer.nTestLong = rand() + myBuffer.nTestFloat;
strcpy(myBuffer.szTestChar, "Viswaaaaaa");
int nRet = doInstrumentation(nThreadID, &myBuffer);
if(nRet < 0)
{
printf("Instrumentation failed");
}
注意:您可以从同一线程调用此 doInstrumentation()
最多 MAX_BUFFERS_PER_THREAD
次。您仍然可以调用 N 次,只是它会覆盖缓冲区,并且会超出 MAX_BUFFERS_PER_THREAD
的限制。
在事后调试中读取缓冲区
正如您所料,这些缓冲区位于进程的全局位置。如果现场出现问题/崩溃,并且客户提供了现场的 core/crashdump,那么通过这种插装,调试将像这样简单:
(gdb) print gGLobalBuffers[5313]
$6 = {
nLookupId = 0,
theRingBuffers = {{
nTestLong = 2138875903,
nTestInt = 1957747793,
nTestFloat = 1.71463757e+09,
szTestChar = "Viswaaaaaa\000\267\224\337w\267", '\000'
<repeats 12 times>, "\330`]\267", '\000' <repeats 13 times>, "`y\267",
nClockTicks = 3084490601
}, {
nTestLong = 0,
nTestInt = 0,
nTestFloat = 0,
szTestChar = '\000' <repeats 47 times>,
nClockTicks = 0
}, {
nTestLong = 0,
nTestInt = 0,
nTestFloat = 0,
szTestChar = '\000' <repeats 47 times>,
nClockTicks = 0
}, {
nTestLong = 0,
nTestInt = 0,
nTestFloat = 0,
szTestChar = '\000' <repeats 47 times>,
nClockTicks = 0
}, {
nTestLong = 0,
nTestInt = 0,
nTestFloat = 0,
szTestChar = '\000' <repeats 47 times>,
nClockTicks = 0
}}
}
(gdb) print gGLobalBuffers[5314]
$7 = {
nLookupId = 0,
theRingBuffers = {{
nTestLong = 3485982825,
nTestInt = 846930886,
nTestFloat = 1.80429005e+09,
szTestChar = "Viswaaaaaa", '\000' <repeats 37 times>,
nClockTicks = 3084490601
}, {
nTestLong = 0,
nTestInt = 0,
nTestFloat = 0,
szTestChar = '\000' <repeats 47 times>,
nClockTicks = 0
}, {
nTestLong = 0,
nTestInt = 0,
nTestFloat = 0,
szTestChar = '\000' <repeats 47 times>,
nClockTicks = 0
}, {
nTestLong = 0,
nTestInt = 0,
nTestFloat = 0,
szTestChar = '\000' <repeats 47 times>,
nClockTicks = 0
}, {
nTestLong = 0,
nTestInt = 0,
nTestFloat = 0,
szTestChar = '\000' <repeats 47 times>,
nClockTicks = 0
}}
}
关注点
ThinloggerMT
是一个简单的环形缓冲区实现,其中缓冲区通过线程 ID 索引。即每个线程都有自己的日志缓冲区,而这些缓冲区又彼此相关。
由于每个线程都有自己的缓冲区,这些线程不相互依赖。因此,线程无需等待锁,从而提高了线程的日志记录时间。
这些缓冲区保存在全局状态中。目前,如果进程 core-dumped,则可以从 core 文件中检查这些缓冲区。未来计划将插装按需转储或使用共享内存。此缓冲区以静态库格式提供。
我在我的 github 页面上发布了代码,并在那里计划将该项目开发为一个功能齐全的诊断库。如果您喜欢并愿意贡献,请随时联系。
历史
- 版本 1 - 静态库格式