【问题标题】:What is the most efficient way to make this code thread safe?使此代码线程安全的最有效方法是什么?
【发布时间】:2010-07-09 09:42:31
【问题描述】:

我正在研究的一些 C++ 库具有一个简单的跟踪机制,可以激活该机制以生成日志文件,显示调用了哪些函数以及传递了哪些参数。它基本上可以归结为TRACE 宏被溢出到整个库的源代码中,并且宏扩展为如下内容:

typedef void(*TraceProc)( const char *msg );

/* Sets 'callback' to point to the trace procedure which actually prints the given
 * message to some output channel, or to a null trace procedure which is a no-op when
 * case the given source file/line position was disabled by the client.
 *
 * This function also registers the callback pointer in an internal data structure
 * and resets it to zero in case the filtering configuration changed since the last
 * invocation of updateTraceCallback.
 */
void updateTraceCallback( TraceProc *callback, const char *file, unsinged int lineno );

#define TRACE(msg)                                                  \
{                                                                   \
    static TraceProc traceCallback = 0;                             \
    if ( !traceCallback )                                           \
        updateTraceCallback( &traceCallback, __FILE__, __LINE__ );  \
    traceCallback( msg );                                           \
}

这个想法是人们可以在他们的代码中只说TRACE("foo hit"),这样要么 调用调试打印函数,否则它将是空操作。他们可以使用其他一些 API(此处未显示)来配置仅应打印位置(源文件/行号)中使用的 TRACE。此配置可以在运行时更改。

这个问题是现在应该在多线程代码库中使用这个想法。因此,TRACE 扩展为的代码需要在面对同时运行代码的多个执行线程时正常工作。现在代码库中大约有 20.000 个不同的跟踪点,而且它们经常被命中,所以它们应该是相当高效的

使这种方法线程安全的最有效方法是什么?我需要适用于 Windows(XP 和更新版本)和 Linux 的解决方案。我担心只是为了检查过滤器配置是否更改而进行过度锁定(99% 的时间跟踪点被命中,配置没有更改)。我也愿意对宏进行更大的更改。因此,与其讨论互斥锁与临界区的性能,不如说宏只是将事件发送到不同线程中的事件循环(假设访问事件循环是线程安全的)并且所有处理都发生在同一个线程中,这也是可以接受的线程,所以它使用事件循环同步。

更新:我大概可以把这个问题简化为:

如果我有一个线程读取指针,而另一个线程可能写入变量(但 99% 的情况下不会),我如何避免读取线程需要一直锁定?

【问题讨论】:

  • 错字。 “updateTraceBallback”而不是“updateTraceCallback”。
  • @SigTerm:谢谢,已修复。
  • 如果配置可以在运行时改变,为什么不把它转换成一个宏,只做一个正常的方法调用,内部检查那个标志?
  • @Lasse:我当然可以这样做,但这并没有多大帮助:对函数查询的标志的访问需要同步,并且在 99% 的情况下标志没有'不要改变所以同步成本被浪费了。我想避免在这里同步(或让它变得非常便宜)。

标签: c++ multithreading logging trace


【解决方案1】:

您可以实现配置文件版本变量。当您的程序启动时,它被设置为 0。宏可以保存一个静态 int,它是它看到的最后一个配置版本。然后在最后一次看到的配置版本和当前配置版本之间进行简单的原子比较,将告诉您是否需要执行完全锁定并重新调用 updateTraceCallback();。

这样,在 99% 的情况下,您只需添加一个额外的原子操作、内存屏障或类似的东西,这非常便宜。 1% 的时间,只做完整的 mutex 事情,如果只有 1% 的时间,它不应该以任何明显的方式影响你的表现。

编辑:

一些.h文件:

extern long trace_version;

一些 .cpp 文件:

long trace_version = 0;

宏:

#define TRACE(msg)                                                  
{
    static long __lastSeenVersion = -1;
    static TraceProc traceCallback = 0;
    if ( !traceCallback || __lastSeenVersion != trace_version )
        updateTraceCallback( &traceCallback, &__lastSeenVersion, __FILE__, __LINE__ );
    traceCallback( msg );
}

增加版本和更新的函数:

static long oldVersionRefcount = 0;
static long curVersionRefCount = 0;

void updateTraceCallback( TraceProc *callback, long &version, const char *file, unsinged int lineno ) {
   if ( version != trace_version ) {
      if ( InterlockedDecrement( oldVersionRefcount ) == 0 ) {
        //....free resources.....
        //...no mutex needed, since no one is using this,,,
      }
      //....aquire mutex and do stuff....
      InterlockedIncrement( curVersionRefCount );
      *version = trace_version;
      //...release mutex...
   }
 }

 void setNewTraceCallback( TraceProc *callback ) {
    //...aquire mutex...
    trace_version++;  // No locks, mutexes or anything, this is atomic by itself.  
    while ( oldVersionRefcount != 0 ) { //..sleep? }
    InterlockedExchange( &oldVersionRefcount, curVersionRefCount );
    curVersionRefCount = 0;
    //.... and so on...
    //...release mutex...

当然,这很简单,因为如果你需要升级版本和oldVersionRefCount > 0,那你就麻烦了;如何解决这个问题取决于你,因为它真的取决于你的问题。我的猜测是,在这些情况下,您可以简单地等到引用计数为零,因为引用计数递增的时间应该是运行宏所花费的时间。

【讨论】:

  • 听起来很有趣!执行原子整数比较的正确方法是什么 - InterlockedCompareExchange?另外,我想我也需要使用原子函数来更改文件版本?
  • 甚至没有。通常你需要的只是一个内存屏障:secure.wikimedia.org/wikipedia/en/wiki/Memory_barrier 也许,甚至不是。 x86 int 操作本身是线程安全的,因此,在您的情况下,如果我没看错,只有 1 个线程会写入,所有其他线程都会读取,所以也许,版本可能不需要锁定,甚至更新它们也不需要. x++ 和 if (x > y) 可能会做,如果您保留旧版本而其他线程仍在使用它们(即尚未看到新版本号)。
  • 明确一点:我提到原子的原因是,如果您在旧版本上添加一个引用计数器,以便最后一个完成使用它并注意到版本更改将释放它。因此,宏中的第一步将是一个InterlockedCompareExchange 操作来增加一个引用计数器。最后一个,另一个InterlockedCompareExchange要减少,如果版本改变,释放/删除资源。
【解决方案2】:

我还没有完全理解这个问题,所以请纠正我没有得到的任何东西。

(我省略了反斜杠。)

#define TRACE(msg)
{
    static TraceProc traceCallback = NULL;
    TraceProc localTraceCallback;

    localTraceCallback = traceCallback;

    if (!localTraceCallback)
    {
        updateTraceBallback(&localTraceCallback, __FILE__, __LINE__);
        // If two threads are running this at the same time 
        // one of them will update traceCallback and get it overwritten 
        // by the other. This isn't a big deal.
        traceCallback = localTraceCallback;
    }

    // Now there's no way localTraceCallback can be null.
    // An issue here is if in the middle of this executing 
    // traceCallback gets null'ed. But you haven't specified any 
    // restrictions about this either, so I'm assuming it isn't a problem.
    localTraceCallback(msg);
}

【讨论】:

  • 只要注意回调不使用过滤器重置后不再有效的任何结构,就不会出现您在上一条评论中提到的问题。
【解决方案3】:

您的评论说“将其重置为零以防过滤配置在运行时发生更改”,但我是否正确地将其解读为“当过滤配置更改时将其重置为零”?

如果不确切知道 updateTraceCallback 如何实现其数据结构,或者它引用了哪些其他数据来决定何时重置回调(或者实际上首先设置它们),就不可能判断什么是安全的。一个类似的问题适用于知道 traceCallback 做了什么——例如,如果它访问一个共享的输出目标。

鉴于这些限制,唯一不需要重新编写其他代码的安全建议是在整个代码块(或者最好是 Windows 上的关键部分)上粘贴一个互斥锁。

【讨论】:

  • 我很抱歉评论的不准确。我现在改写了一下,希望它更清楚。 updateTraceCallback 维护一个简单的std::set<TraceProc*>,其中包含迄今为止看到的所有回调(请注意,它们的指针值是唯一的,因为它们都是静态的)。如果指针不在集合中,则添加它。如果过滤器配置发生变化,则集合中的所有回调都设置为零,以便在下次执行 TRACE 宏时重新初始化。
  • std::set 不是线程安全的,因此如果不对所有 std::set 访问使用锁,您根本无法安全地继续该设计。
【解决方案4】:

我担心只是为了检查过滤器配置是否更改而进行过度锁定(99% 的时间跟踪点被命中,配置没有更改)。我也愿意对宏进行更大的更改。因此,与其讨论互斥锁与临界区的性能,不如说如果宏只是将事件发送到不同线程中的事件循环(假设访问事件循环是线程安全的)也是可以接受的

您认为线程之间的线程安全消息传递是如何在没有锁的情况下实现的?

无论如何,这是一个可行的设计:

必须更改保存过滤器的数据结构,以便从堆中动态分配它,因为我们将创建多个过滤器实例。此外,它需要添加一个引用计数。你需要一个 typedef,比如:

typedef struct Filter
{
    unsigned int refCount;
    // all the other filter data
} Filter;

在某处声明了一个单例“当前过滤器”。

static Filter* currentFilter;

并使用一些默认设置进行初始化。

在您的 TRACE 宏中:

#define TRACE(char* msg)
{
    static Filter* filter = NULL;
    static TraceProc traceCallback = NULL;

    if (filterOutOfDate(filter))
    {
        getNewCallback(__FILE__, __LINE__, &traceCallback, &filter);
    }
    traceCallback(msg);
}

filterOutOfDate() 只是将过滤器与 currentFilter 进行比较,看是否相同。只比较地址就足够了。它没有锁定。

getNewCallback() 应用当前过滤器来获取新的跟踪函数,并使用当前过滤器的地址更新传入的过滤器。它的实现必须用互斥锁保护。此外,它会减少原始过滤器的 refCount 并增加新过滤器的 refCount。这样我们就知道什么时候可以释放旧的过滤器了。

void getNewCallback(const char* file, int line, TraceProc* newCallback, Filter** filter)
{
    // MUTEX lock
    newCallback = // whatever you need to do
    currentFilter->refCount++;
    if (*filter != NULL)
    {
        *filter->refCount--;
        if (*filter->refCount == 0)
        {
            // free filter and associated resources
        }
    }
    *filter = currentFilter;
    // MUTEX unlock
}

当你想改变过滤器时,你可以这样做

changeFilter()
{ 
    Filter* newFilter = // build a new filter
    newFilter->refCount = 0;
    // MUTEX lock (same mutex as above)
    currentFilter = newFilter;
    // MUTEX unlock
}

【讨论】:

    【解决方案5】:

    如果我有一个线程读取指针,而另一个线程可能会写入变量(但 99% 的时间它不会),我如何避免读取线程需要一直锁定?

    根据您的代码,可以在 updateTraceCallback() 中使用互斥锁,因为它很少被调用(每个位置一次)。取完互斥体后,检查traceCallback是否已经初始化:如果是,那么其他线程只是为你做了,没有什么可做的。

    如果 updateTraceCallback() 由于全局互斥体的冲突而成为严重的性能问题,那么您可以简单地创建一个互斥体数组,并使用 traceCallback 指针的哈希值作为索引互斥数组。这会将锁定分散到许多互斥体上并最大限度地减少冲突次数。

    【讨论】:

    • +1 使用互斥体数组的想法很有趣,感谢您提出这个问题!
    • 据我所知,问题在于所有这些不同的 updateTraceCallback 调用都访问一个共享数据结构。多个互斥锁在这里没有帮助,也无法防止该结构被破坏。互斥争用是问题的症状,而不是问题本身。
    【解决方案6】:
    #define TRACE(msg)                                                  \
    {                                                                   \
        static TraceProc traceCallback =                                \
            updateTraceBallback( &traceCallback, __FILE__, __LINE__ );  \
        traceCallback( msg );                                           \
    }
    

    【讨论】:

    • 很遗憾,这不是一个选项,因为 updateTraceCallback 函数只会被调用一次,要么将 traceCallback 初始化为无操作函数,要么初始化为真正的日志记录函数。然而,过滤可以在运行时改变,所以要么updateTraceCallback需要被重复调用,要么需要一些锁定,因为traceCallback可以被另一个线程设置为指向no-op函数(因为配置改变了) .
    • 此外,根据blogs.msdn.com/b/oldnewthing/archive/2004/03/08/85901.aspx,作用域静态初始化显然不是线程安全的
    • @Frerich:就所有意图和目的而言,您的原始版本中的语义与正常的静态初始化相同,因此无论我的版本有什么问题,您的版本也有问题。如果过滤在运行时发生变化,无论有没有线程,这两个版本都不能正常工作。关于线程:我在过去 15 年中使用过的每个编译器都会发出线程安全的静态初始化器。告诉你的编译器吐出一些汇编程序,然后自己看看。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2010-10-01
    • 1970-01-01
    相关资源
    最近更新 更多