在pthread之间同步一个简单标志,是否需要使用互斥锁?

18

假设我有一些工作线程如下:

while (1) {
    do_something();

    if (flag_isset())
        do_something_else();
}

我们有几个辅助函数用于检查和设置标志:

void flag_set()   { global_flag = 1; }
void flag_clear() { global_flag = 0; }
int  flag_isset() { return global_flag; }
因此,线程在繁忙循环中不断调用do_something(),如果其他线程设置了global_flag,线程也会调用do_something_else()(例如当请求通过从另一个线程设置标志来输出进度或调试信息时)。
我的问题是:我需要做一些特殊的事情来同步访问global_flag吗?如果是,那么在可移植性方面,最小化同步工作要做什么? 我尝试通过阅读许多文章来弄清楚这个问题,但我仍然不确定正确答案是什么...我认为以下两种情况之一是正确的:

A:无需同步,因为设置或清除标志不会创建竞争条件:

我们只需要将该标志定义为volatile,以确保每次检查时它都真正地从共享内存中读取:
volatile int global_flag;

可能不会立即传播到其他CPU核心,但肯定迟早会传播。

B: 为确保标志的更改在线程之间传播,需要进行完全同步:

在一个CPU核心中设置共享标志并不一定能被另一个核心看到。我们需要使用互斥锁来确保通过无效化其他CPU上的相应缓存行始终传播标志更改。代码如下:

volatile int    global_flag;
pthread_mutex_t flag_mutex;

void flag_set()   { pthread_mutex_lock(flag_mutex); global_flag = 1; pthread_mutex_unlock(flag_mutex); }
void flag_clear() { pthread_mutex_lock(flag_mutex); global_flag = 0; pthread_mutex_unlock(flag_mutex); }

int  flag_isset()
{
    int rc;
    pthread_mutex_lock(flag_mutex);
    rc = global_flag;
    pthread_mutex_unlock(flag_mutex);
    return rc;
}

C:需要同步以确保标志的更改在线程之间传播:

这与B相同,但不是在读取端和写入端都使用互斥量,而是仅在写入端设置。因为逻辑不需要同步。我们只需要在更改标志时同步(使其他缓存失效):

volatile int    global_flag;
pthread_mutex_t flag_mutex;

void flag_set()   { pthread_mutex_lock(flag_mutex); global_flag = 1; pthread_mutex_unlock(flag_mutex); }
void flag_clear() { pthread_mutex_lock(flag_mutex); global_flag = 0; pthread_mutex_unlock(flag_mutex); }

int  flag_isset() { return global_flag; }

当我们知道标志很少更改时,这将避免不断地锁定和解锁互斥体。我们只是利用了Pthreads互斥体的一个副作用,以确保更改被传播。

那么,哪一个?

我认为A和B是明显的选择,B更安全。但C呢?

如果C可以,是否有其他方法强制在所有CPU上都可见标志更改?

还有一个相关的问题:使用pthread互斥体保护变量是否保证它也不会被缓存?…但这并没有真正回答这个问题。


1
请注意,如果您使用GNU C,则可以使用类型sig_atomic_t声明变量,以确保可以在一条指令中完成get/set操作。 - Michael Mior
sig_atomic_t 使变量的访问具有原子性(这并不是这个问题的重点),但这是否保证了缓存一致性?或者说,首先是否存在任何需要担心的缓存一致性问题? - snap
1
不,这与缓存一致性无关。这可能应该作为对Sparky答案的评论,因为它在那个上下文中是相关的。 - Michael Mior
4个回答

14

“最小工作量”是一种显式的内存屏障。具体语法取决于您的编译器;在GCC上,您可以这样做:

void flag_set()   {
  global_flag = 1;
  __sync_synchronize(global_flag);
}

void flag_clear() {
  global_flag = 0;
  __sync_synchronize(global_flag);
}

int  flag_isset() {
  int val;
  // Prevent the read from migrating backwards
  __sync_synchronize(global_flag);
  val = global_flag;
  // and prevent it from being propagated forwards as well
  __sync_synchronize(global_flag);
  return val;
}

这些内存屏障实现了两个重要的目标:

  1. 它们强制编译器刷新。考虑以下循环:

     for (int i = 0; i < 1000000000; i++) {
       flag_set(); // assume this is inlined
       local_counter += i;
     }
    

    没有屏障的话,编译器可能会选择对其进行优化:

     for (int i = 0; i < 1000000000; i++) {
       local_counter += i;
     }
     flag_set();
    

    插入屏障会强制编译器立即写回变量。

  2. 它们强制CPU对其写入和读取进行排序。对于单个标志,这不是太大的问题 - 大多数CPU架构最终会看到已设置的标志而不需要CPU级屏障。但是顺序可能会改变。如果我们有两个标志,并且在线程A中:

  3.   // start with only flag A set
      flag_set_B();
      flag_clear_A();
    

    而在线程B上:

      a = flag_isset_A();
      b = flag_isset_B();
      assert(a || b); // can be false!
    
    某些CPU架构允许对这些写入进行重新排序; 你可能会看到两个标志都为false (即,标志A的写入被首先移动)。如果一个标志保护着一个指针的有效性,那么这可能会成为一个问题。内存栅栏强制对写入进行排序以防止这些问题。

    此外,请注意,在某些CPU上,可以使用“获取-释放”栅栏语义来进一步减少开销。然而,在x86上不存在这样的区别,需要在GCC上使用内联汇编。

    有关内存栅栏是什么以及为什么需要它们的很好概述可以在Linux内核文档目录中找到。最后,请注意,此代码足以用于单个标志,但如果您想同步其他任何值,必须非常小心。锁通常是最简单的方法。


谢谢您提供这个出色的答案!它正好解决了我不确定的问题。由于这些内存屏障定义是编译器和/或操作系统特定的,可移植代码无法使用它们。就像您所说的:“锁通常是最简单的方法。”您能否详细说明一下(回到我的原始问题):选项C是否保证正确,因为它使用了(否则不需要的“虚拟”锁),或者在每次迭代中读取侧也需要锁定调用?什么是最有效但仍然可移植的执行内存屏障的方法? - snap
回答我之前的评论:我想没有可靠的方法可以跳过_read_方面的锁定,因为该方面仍可能容易受到有趣的编译器或CPU读取顺序的影响。当我们实际上不需要真正的锁定而只需要内存屏障时,调用其他一些pthread_*函数是否比pthread_mutex_lock()后跟pthread_mutex_unlock()更有效?在Open Group列表中还有许多执行内存同步的函数。 - snap
1
好的,这个https://dev59.com/WXM_5IYBdhLWcg3w4nfb回答了上述问题。即使程序逻辑不需要真正的互斥锁时,Pthreads mutex仍是使用Pthreads时获取内存屏障的正确方式。 - snap

4
必须避免数据竞争情况。这是未定义的行为,编译器可以任意执行任何操作。
以下是一个有趣的关于此主题的博客:http://software.intel.com/en-us/blogs/2013/01/06/benign-data-races-what-could-possibly-go-wrong 案例1:标志上没有同步,因此任何事情都可能发生。例如,编译器可以将其转换为:
flag_set();
while(weArentBoredLoopingYet())
    doSomethingVeryExpensive();
flag_clear()

转化为

while(weArentBoredLoopingYet())
    doSomethingVeryExpensive();
flag_set();
flag_clear()

注意:这种竞争方式实际上非常流行。你的里程可能会有所不同。一方面,pthread_call_once的事实实现涉及到这样的数据竞争。另一方面,它是未定义行为。在大多数版本的gcc中,你可以做到这一点,因为gcc选择在许多情况下不优化这种方式,但它不是“规范”代码。
B:完全同步是正确的选择。这就是你必须要做的。
C:只有对写入者进行同步才能起作用,如果你能证明在写入时没有人想读取它。官方定义数据竞争(来自C++11规范)是一个线程向变量写入,而另一个线程可以同时读取或写入相同的变量。如果你的读者和写者都同时运行,你仍然有一个竞争情况。然而,如果你能证明写入者只写入一次,有一些同步,然后读者都阅读,那么读者就不需要同步。
至于缓存,规则是mutex lock/unlock与锁定/解锁相同mutex的所有线程同步。这意味着你不会看到任何异常的缓存效应(虽然在底层,你的处理器可以做出惊人的事情,使其运行得更快……它只是被迫让它看起来像它没有做任何特殊的事情)。然而,如果你不同步,你就无法保证另一个线程没有要推送的更改!
所有这些都说了,问题实际上是你愿意依赖于编译器特定的行为有多少。如果你想写正确的代码,你需要进行适当的同步。如果你愿意依赖于编译器对你友好,你可以少做很多事情。
如果你有C++11,简单的答案是使用atomic_flag,它被设计成正好做你想要的事情,并且在大多数情况下为你正确地同步。

0

针对您所发布的示例,情况A已经足够,只要...

  1. 获取和设置标志只需要一条CPU指令。
  2. do_something_else()在执行该例程期间不依赖于标志是否被设置。

如果获取和/或设置标志需要多于一条CPU指令,则必须使用某种形式的锁定。

如果do_something_else()在执行该例程期间依赖于标志是否被设置,则必须像情况C一样进行锁定,但是在调用flag_isset()之前必须锁定互斥量。

希望这可以帮助到您。


do_something()和do_something_else()不会触碰标志(flag),也不一定调用任何pthread例程。为什么使用一条CPU指令可以保证其他CPU缓存被失效? - snap
@snap: 我忘记了C案例涉及到多处理器。我通常习惯于处理单处理器系统。我不确定在MP系统上会发生什么。我期望(也许是错误的)硬件应该处理同步问题。如果我错了,请其他人纠正我。 - Sparky
我担心硬件不一定会自动处理内存同步。例如,我查看了http://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap04.html#tag_04_11。它讨论了某些函数执行内存同步。如果这种同步已经得到保证,为什么他们还要特别列出在线程之间同步内存的函数呢? - snap
1
@snap 大多数(全部?)多核处理器都有缓存一致性协议,确保负载和存储在处理器之间可见。最大的问题在于编译器和CPU指令重新排序,这是通过内存屏障来处理的(请参见bdonlan的答案)。大多数pthread原语最终将使用原子操作数,例如getAndIncrement或compareAndSwap,这些是自然内存屏障。这确保同步指令不会在锁定和解锁调用形成的边界之外重新排序,并保证所有负载和存储在解锁调用后可见。 - Ze Blob

-2

将传入的作业分配给工作线程不需要锁定。典型的例子是Web服务器,请求由主线程捕获,然后该主线程选择一个工作线程。我正在尝试用一些伪代码来解释它。

main task {

  // do forever
  while (true)

    // wait for job
    while (x != null) {
      sleep(some);
      x = grabTheJob(); 
    }

    // select worker
    bool found = false;
    for (n = 0; n < NUM_OF_WORKERS; n++)
     if (workerList[n].getFlag() != AVAILABLE) continue;
     workerList[n].setJob(x);
     workerList[n].setFlag(DO_IT_PLS);
     found = true;
    }

    if (!found) panic("no free worker task! ouch!");

  } // while forever
} // main task


worker task {

  while (true) {
    while (getFlag() != DO_IT_PLS) sleep(some);
    setFlag(BUSY_DOING_THE_TASK);

    /// do it really

    setFlag(AVAILABLE);

  } // while forever 
} // worker task

所以,如果有一个标志,其中一个方将其设置为A,另一个方将其设置为B和C(主任务将其设置为DO_IT_PLS,工作人员将其设置为BUSY和AVAILABLE),则没有冲突。用“现实生活”例子来玩,比如老师给学生不同的任务。老师选择一个学生,给他/她一个任务。然后,老师寻找下一个可用的学生。当学生准备好时,他/她回到可用学生的池中。

更新:只是澄清一下,只有一个main()线程和几个 - 可配置数量的 - 工作线程。由于main()仅运行一个实例,因此无需同步工作者的选择和启动。


1
setJobsetFlag之间,以及getFlag()和实际访问结果作出的地方之间,您需要一个内存屏障。如果不这样做,在某些体系结构上可能会导致微妙的内存排序错误。有关详细信息,请参见http://www.kernel.org/doc/Documentation/memory-barriers.txt。 - bdonlan
2
这是我见过的最糟糕的示例代码之一。它有很多问题,比如考虑两个线程同时运行“选择工作人员”的代码。你要“睡眠”多久?太长时间会延迟每个任务,增加你耗尽工作人员的机会。时间不够长,你会浪费CPU。此外,该代码强制选择工作人员,而不是让调度程序自由选择最有效的工作人员。(因此,如果你的工作比核心多,你将需要更多的上下文切换。)这是犯了所有可能的错误的一个例子! - David Schwartz
1
我找到了一个很好的简单示例,实际演示了这种情况如何注定会失败:http://jakob.engbloms.se/archives/65...它失败的频率因CPU架构的不同而异,但它失败的次数令人惊讶! - snap
亲爱的David Schwartz,main()函数在一个实例中运行,因此不需要同步。只有单个实例的main()函数将工作任务设置为DO_IT_PLS。 - ern0
1
问题在于当一个线程进行内存写入时,不能保证另一个线程会看到该写入。例如,它可能将值缓存在CPU寄存器中。这就是为什么需要锁定的原因。 - David Schwartz
显示剩余2条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接