首页 文章

互斥是否需要在pthreads之间同步一个简单的标志?

提问于
浏览
16

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

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() (例如,可以通过从另一个线程设置标志来请求时输出进度或调试信息) .

我的问题是: Do I need to do something special to synchronize access to the global_flag? If yes, what exactly is the minimum work to do the synchronization in a portable way?

我试图通过阅读许多文章来解决这个问题,但我仍然不太确定正确的答案......我认为它是以下之一:

答:无需同步,因为设置或清除标志不会产生竞争条件:

我们只需将标志定义为 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上都可见吗?

有一个相关的问题:Does guarding a variable with a pthread mutex guarantee it's also not cached? ......但它并没有真正回答这个问题 .

4 回答

  • -1

    将传入作业分配给工作线程不需要锁定 . 典型的例子是webserver,其中请求由主线程捕获,并且该主线程选择一个worker . 我正在尝试用一些伪造的代码来解释它 .

    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),则没有标志 . 用“现实生活”的例子来说,当老师给学生提供不同的任务时 . 老师选择一个学生,给他/她一个任务 . 然后,老师寻找下一个可用的学生 . 当学生准备好后,他/她会回到可用学生的池中 .

    UPDATE :简单地说,只有一个main()线程和几个 - 可配置数量的 - 工作线程 . 由于main()只运行一个实例,因此无需同步worker的选择和launc .

  • 0

    must 不会导致数据争用案例 . 它是未定义的行为,允许编译器做任何事情和它喜欢的一切 .

    一个关于这个主题的幽默博客: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()
    

    注意:这种比赛实际上很受欢迎 . 你的millage可能会有所不同 . 一方面,pthread_call_once的事实上的实现涉及这样的数据竞争 . 另一方面,它是未定义的行为 . 在gcc的大多数版本中,你可以侥幸使用它,因为gcc选择在许多情况下不会行使其优化这种方式的权利,但它不是“规范”代码 .

    B:完全同步是正确的呼叫 . 这就是你必须要做的事情 .

    C:如果你能证明在写作时没有人想要阅读它,那么只有编写器上的同步可以工作 . 数据竞争的官方定义(来自C 11规范)是一个线程写入变量,而另一个线程可以同时读取或写入相同的变量 . 如果您的读者和作家都是一次性的,那么您仍然会遇到一个竞争案例 . 但是,如果你能证明作者写了一次,有一些同步,然后读者都读了,那么读者不需要同步 .

    至于缓存,规则是互斥锁定/解锁与锁定/解锁相同互斥锁的所有线程同步 . 这意味着你不会看到任何不寻常的缓存效果(虽然在引擎盖下,你的处理器可以做一些壮观的事情来让它运行得更快......它只是让它看起来没有做任何特别的事情) . 但是,如果不进行同步,则无法保证其他线程没有更改以推送您所需的内容!

    所有这些都说,问题是你真的愿意依赖编译器特定的行为 . 如果要编写正确的代码,则需要进行适当的同步 . 如果你愿意依赖编译器对你很友善,那么你就可以逍遥法外 .

    如果你有C 11,那么简单的答案是使用atomic_flag,它被设计成完全符合你想要的并且在大多数情况下被设计为正确地同步 .

  • 3

    对于您发布的示例,案例A已足够......

    • 获取和设置标志只需要一条CPU指令 .

    • do_something_else()不依赖于在执行该例程期间设置的标志 .

    如果获取和/或设置标志需要多个CPU指令,那么您必须使用某种形式的锁定 .

    如果do_something_else()依赖于在执行该例程期间设置的标志,则必须按照情况C锁定,但必须在调用flag_isset()之前锁定互斥锁 .

    希望这可以帮助 .

  • 12

    “最小工作量”是明确的记忆障碍 . 语法取决于您的编译器;在海湾合作委员会,你可以做:

    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;
    }
    

    这些记忆障碍实现了两个重要目标:

    • 他们强制编译器刷新 . 考虑如下循环:
    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();
    

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

    • 它们强制CPU命令其写入和读取 . 这不是一个单一标志的问题 - 大多数CPU架构最终会看到一个没有CPU级别障碍的标志 . 但是订单可能会改变 . 如果我们有两个标志,并在线程A上:
    // 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架构允许重新排序这些写入;你可能会看到两个标志都是假的(即标志A写入先被移动) . 如果标志保护指针有效,则这可能是一个问题 . 内存屏障强制对写入进行排序以防止这些问题 .

    另请注意,在某些CPU上,可以使用“获取 - 释放”屏障语义来进一步降低开销 . 然而,在x86上不存在这种区别,并且需要在GCC上进行内联汇编 .

    可以在the Linux kernel documentation directory中找到有关内存障碍及其需要原因的详细概述 . 最后,请注意,此代码足以用于单个标志,但如果您想要与任何其他值同步,则必须非常小心 . 锁通常是最简单的做事方式 .

相关问题