【问题标题】:Two threads incrementing a number两个线程递增一个数字
【发布时间】:2022-01-19 20:59:50
【问题描述】:

这是给我的一个测试任务,我显然失败了:

1.使用两个线程递增一个整数。线程 A 在偶数时递增,线程 B 在奇数时递增(对于整数问题,我们可以将其指定为命令行中提供的最大数字)

1a。添加更多线程有哪些困难?请说明代码的困难。

1b。额外的功劳——为上述问题设计一个改进的解决方案,可以通过多个线程进行扩展

第一次尝试后的反馈是“没有解决原子修改和错误共享”。我试图解决它们,但第二次尝试没有反馈。我想用这个测试来学习,所以我想我会问最顶尖的专家——你。

以下是第一次尝试的头部:

#include <iostream>
#include <mutex>
#include <atomic>

class CIntToInc
{
private:
 int m_nVal; //std::atomic<int> m_nVal;
 int m_nMaxVal;
public:
 CIntToInc(int p_nVal, int p_nMaxVal) : m_nVal(p_nVal), m_nMaxVal(p_nMaxVal) { }
 const int GetVal() const { return m_nVal; }
 const int GetMaxVal() const { return m_nMaxVal; }
 void operator ++() { ++m_nVal; }
};

struct COper
{
 enum class eOper { None = 0, Mutex = 1, NoMutex = 2 };
 eOper m_Oper;
public:
 friend std::istream& operator>> (std::istream &in, COper &Oper);
 bool operator == (const eOper &p_eOper) { return(m_Oper == p_eOper); }
};

以下是第一次尝试的来源。它包括我对解决方案为何有效的想法。我在MSVS2012中编译了代码。

// Notes: 
// 1a.
// Since an integer cannot be an odd number and an even number at the same time, thread separation happens naturally when each thread checks the value.
// This way no additional synchronization is necessary and both threads can run at will, provided that it's all they are doing.
// It's probably not even necessary to declare the target value atomic because it changes (and thus lets the other thread increment itself) only at the last moment.
// I would still opt for making it atomic.
// Adding more threads to this setup immediately creates a problem with threads of equal condition (even or odd) stepping on each other.
// 1b.
// By using a mutex threads can cleanly separate. Many threads with the same condition can run concurrently.
// Note: there is no guarantee that each individual thread from a pool of equally conditioned threads will get to increment the number.
// For this method reading has to be inside the mutext lock to prevent a situation where a thread may see the value as incrementable, yet when it gets to it, the value has already 
// been changed by another thread and no longer qualifies.
// cout message output is separated in this approach.
// 
// The speed of the "raw" approach is 10 times faster than that of the mutex approach on an equal number of threads (two) with the mutex time increasing further as you add threads.
// Use 10000000 for the max to feel the difference, watch the CPU graph
//
// If the operation is complex and time consuming, the approach needs to be different still. The "increment" functionality can be wrapped up in a pimpl class, a copy can be made
// and "incremented". When ready, the thread will check for whether the value has changed while the operation was being performed on the copy and, if not, a fast swap under the mutex
// could be attempted. This approach is resource-intensive, but it mininuzes lock time.
//
// The approach above will work if the operation does not involve resources that cannot be easily copied (like a file to the end of which we are writing)
// When such resources are present, the algorithm probably has to implement a thread safe queue.
// END

#include "test.h"
#include <thread>

int main_test();

int main(int argc, char* argv[])
{
 main_test();
 return(0);
}

void IncrementInt2(CIntToInc &p_rIi, bool p_bIfEven, const char *p_ThreadName, std::mutex *p_pMu)
// the version that uses a mutex
// enable cout output to see thread messages
{
 int nVal(0);
 while(true) {
   p_pMu->lock();
   bool DoWork = (nVal = p_rIi.GetVal() < p_rIi.GetMaxVal());
   if(DoWork) {
     //std::cout << "Thread " << p_ThreadName << ": nVal=" << nVal << std::endl;
     if((!(nVal % 2) && p_bIfEven) || (nVal % 2 && !p_bIfEven)) {
      //std::cout << "incrementing" << std::endl;
      ++p_rIi; } }
   p_pMu->unlock();
   if(!DoWork) break;
   //if(p_bIfEven) // uncomment to force threads to execute differently
   // std::this_thread::sleep_for(std::chrono::milliseconds(10));
   }
}

void IncrementInt3(CIntToInc &p_rIi, bool p_bIfEven, const char *p_ThreadName)
// the version that does not use a mutex
// enable cout output to see thread messages. Message text output is not synchronized
{
 int nVal(0);
 while((nVal = p_rIi.GetVal()) < p_rIi.GetMaxVal()) {
   //std::cout << "Thread " << p_ThreadName << ": nVal=" << nVal << std::endl;
   if((!(nVal % 2) && p_bIfEven) || (nVal % 2 && !p_bIfEven)) {
    //std::cout << "Thread " << p_ThreadName << " incrementing" << std::endl;
    ++p_rIi; }
    }
}

std::istream& operator>> (std::istream &in, COper &Oper)
// to read operation types from cin
{
 int nVal;
 std::cin >> nVal;
 switch(nVal) {
   case 1: Oper.m_Oper = COper::eOper::Mutex; break;
   case 2: Oper.m_Oper = COper::eOper::NoMutex; break;
   default: Oper.m_Oper = COper::eOper::None; }
 return in;
}

int main_test()
{
 int MaxValue, FinalValue;
 COper Oper;
 std::cout << "Please enter the number to increment to: ";
 std::cin >> MaxValue;
 std::cout << "Please enter the method (1 - mutex, 2 - no mutex): ";
 std::cin >> Oper;

 auto StartTime(std::chrono::high_resolution_clock::now());

 if(Oper == COper::eOper::Mutex) {
   std::mutex Mu;
   CIntToInc ii(0, MaxValue);
   std::thread teven(IncrementInt2, std::ref(ii), true, "Even", &Mu);
   std::thread todd(IncrementInt2, std::ref(ii), false, "Odd", &Mu);
   // add more threads at will, should be safe
   //std::thread teven2(IncrementInt2, std::ref(ii), true, "Even2", &Mu);
   //std::thread teven3(IncrementInt2, std::ref(ii), true, "Even3", &Mu);
   teven.join();
   todd.join();
   //teven2.join();
   //teven3.join();
   FinalValue = ii.GetVal();
   }
 else if(Oper == COper::eOper::NoMutex) {
   CIntToInc ii(0, MaxValue);
   std::thread teven(IncrementInt3, std::ref(ii), true, "Even");
   std::thread todd(IncrementInt3, std::ref(ii), false, "Odd");
   teven.join();
   todd.join();
   FinalValue = ii.GetVal(); }

 std::chrono::duration<double>elapsed_seconds = (std::chrono::high_resolution_clock::now() - StartTime);
 std::cout << "main_mutex completed with nVal=" << FinalValue << " in " << elapsed_seconds.count() << " seconds" << std::endl;

 return(0);
}

对于第二次尝试,我对标题进行了以下更改:
制作 m_nVal std::atomic
使用原子方法来增加和检索 m_nVal
用填充符将 m_nVal 与只读 m_nMaxVal 分开
源文件没有改变。新标题如下。

#include <iostream>
#include <mutex>
#include <atomic>
class CIntToInc
{
private:
 int m_nMaxVal;
 char m_Filler[64 - sizeof(int)]; // false sharing prevention, assuming a 64 byte cache line
 std::atomic<int> m_nVal;

public:
 CIntToInc(int p_nVal, int p_nMaxVal) : m_nVal(p_nVal), m_nMaxVal(p_nMaxVal) { }
 const int GetVal() const { 
   //return m_nVal;
   return m_nVal.load(); // std::memory_order_relaxed);
   }
 const int GetMaxVal() const { return m_nMaxVal; }
 void operator ++() { 
   //++m_nVal;
   m_nVal.fetch_add(1); //, std::memory_order_relaxed); // relaxed is enough since we check this very variable
   }
};

struct COper
{
 enum class eOper { None = 0, Mutex = 1, NoMutex = 2 };
 eOper m_Oper;
public:
 friend std::istream& operator>> (std::istream &in, COper &Oper);
 bool operator == (const eOper &p_eOper) { return(m_Oper == p_eOper); }
};

我不知道这种方法是否从根本上是错误的,或者是否存在一个或多个较小的错误。

【问题讨论】:

  • 您的代码看起来设计过度。你不增加int,你使用了一些类——我认为这违反了你的要求。
  • char m_Filler[64 - sizeof(int)]; 为什么不只是alignas(64) std::atomic&lt;int&gt; m_nVal;?或者使用更便携的 C++17:alignas(std::hardware_destructive_interference_size) std::atomic&lt;int&gt; m_nVal;。另外为什么将第一个参数作为引用传递,而是指向互斥体的指针?顺便说一句:在等待值变为奇数甚至偶数时保持线程运行可能不是最好的主意......也许使用 2 std::condition_variables 可能是个好主意? (一个用于“奇数可用”,一个用于“可用偶数”,每次递增后,在右侧使用notify_one...)
  • “它会改变......仅在最后一刻”并不意味着您可以跳过同步。如果不止一个线程访问一个对象,并且这些线程中至少有一个线程写入该对象并且您不同步访问,则行为未定义。

标签: c++ multithreading concurrency stl


【解决方案1】:

你为什么不需要同步的推理是有缺陷的。你确实需要同步,即使每个线程自然会交替谁是作者。正如Pete Becker 所说,没有同步的作者和读者是未定义的行为。您无法预测它将如何中断,但有时优化器可以看到它对您的代码做出假设,并做坏事:

这里有一个线程立即将 keep_going 设置为 false,这“应该”停止循环:

int main() {
    bool keep_going = true;
    unsigned x = 999;

    auto thr = std::thread([&]() mutable { 
        keep_going = false;  // unsync write ...
    });   

    while (keep_going) {     // ... unsync read - undefined behavior
       ++x;
    }

    thr.join();
    std::cout << x << std::endl;
}

直播:https://godbolt.org/z/P1rnf8s71

但是,它永远不会停止与 g++ 一起运行!为什么?循环优化器看到了几件事:

  1. 虽然 keep_going 在 lambda 中使用,但由于没有同步,它并不能说明它在后台线程中运行。
  2. 因此,当它到达循环时,如果 lambda 想要改变它,它已经改变了。
  3. 由于没有向 keep_going 写入任何内容,因此在进入循环时它的状态不会改变,因此可以将测试提升到循环之外。
  4. 类似地,由于循环无法退出,并且循环仅写入 x,如果它确实没有写入 x,则无法观察到,因此它消除了浪费的工作。

优化器因此与 AS IF 等价:

bool keep_going = true;
call_ordinary_function(keep_going);
if (keep_going) {
   top:
   goto top;
}

生成的程序集反映了这一点:

        call    [QWORD PTR [rax+8]]
.L7:
        cmp     BYTE PTR [rsp+31], 0
        je      .L30

.L8:
        jmp     .L8    <<<< truly infinite loop

.L30:

不符合你的预期?

但是声明布尔值 atomic 会改变一切:

std::atomic<bool> keep_going = true;

现在生成的代码是:

.L7:
        mov     ebx, 999
        jmp     .L8
.L11:
        add     ebx, 1
.L8:
        movzx   eax, BYTE PTR [rsp+31]
        test    al, al
        jne     .L11
        lea     rdi, [rsp+32]

所以现在我们看到了:

  1. x 现在递增(因为循环可以终止,所以对x 的更改在循环后可见),
  2. 我们不断加载keep_going的值,将其读入eax并在循环中实际检查。
  3. 它实际上终止了。

我希望这能让您相信,即使您认为它没有必要,生成的代码也可能不是您想的那样。

【讨论】:

    【解决方案2】:

    首先,关键部分(锁定+解锁)包含奇/偶检查,并在活动循环中完成。因此,这两个线程将竞争性地尝试锁定互斥锁,尽管只有一个线程应该这样做。在最坏的情况下,线程 1 增加值,然后忙锁定+解锁互斥锁(以主动执行检查),而另一个线程 2 等待很长时间才能锁定值增加值。这种情况远不是理论上的,因为线程 1 通常具有互斥锁的优先级(由于 CPU 缓存和操作系统的工作方式)。

    解决此问题的一种方法是使用condition variables。这个想法是锁定一个互斥体,然后增加值,然后通知下一个可以增加值的线程,然后等待被线程唤醒。这个解决方案可以很好地扩展,但如果工作非常小,它通常会很慢,因为等待会导致一些不必要的延迟(通常是由于上下文切换)。当线程数远大于内核数时,这种解决方案非常有效。当线程数量较少时(或者当有很多线程并且即将轮到时),可以使用 atomic 变量上的 busy read 来降低成本线程)。

    另一种解决方案是使用两个(二进制)semaphore。一开始,一个是获得的,一个是没有的。每个线程都尝试获取自己的信号量,递增整数,然后释放另一个,从而导致类似乒乓球的执行。

    虚假分享是您第一次尝试时遇到的最少问题。实际上,虽然互斥体和递增整数之间可能存在错误共享,但这不是问题,因为互斥体保护整数(并且还负责线程之间整数的可见性)。

    请注意,您可以使用lock_guard 使代码更安全且更易于阅读。此外,条件(!(nVal % 2) &amp;&amp; p_bIfEven) || (nVal % 2 &amp;&amp; !p_bIfEven) 比它应该的要复杂得多。考虑使用(nVal % 2) ^ p_bIfEven

    对于第二次尝试,尚不清楚您是否使用带有互斥锁的原子。请注意,没有必要一起使用它们。事实上,由于原子引起的额外开销,这是一个坏主意。话虽如此,如果您选择仅使用原子变量,那么您需要一个(弱)compare and swap,以便检查原子变量的值并原子地更改它。只要线程数小于内核数(由于忙等待),此解决方案就很快。

    关于第二次尝试的虚假分享,m_Filler 不足以保证没有虚假分享(也不是很直接)。实际上,std::atomic 之后存储的内容可能会导致错误共享(std::atomic 不能保证使用一些填充来防止错误共享,实际上通常不会)。您可以使用alignas(64) std::atomic&lt;int&gt; m_nVal; alignas(64) char padding; 解决此问题。请注意,使用 64 取决于架构,理论上应该使用 alignas(std::hardware_destructive_interference_size)

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 2020-08-31
      • 1970-01-01
      • 2019-06-28
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多