【问题标题】:While loop in main thread is getting stuck when using std::thread使用 std::thread 时,主线程中的循环卡住了
【发布时间】:2019-12-01 21:47:04
【问题描述】:

我有一个简单的 C++ 代码来测试和理解线程。该代码具有主线程+辅助线程。 次要更新主线程循环所依赖的变量的值。当我在主循环中添加一个打印语句时,程序成功完成,但是当我删除这个打印语句时,它进入一个无限循环。 这是我正在使用的代码,我所指的打印语句是打印语句 2

#include <mpi.h>
#include <iostream>
#include <fstream>
#include <thread>
#include <mutex>
std::mutex mu;
int num;
using namespace std;

void WorkerFunction()
{
    bool work = true;
    while(work)
    {
            mu.lock();
            num --;
            mu.unlock();

            if(num == 1)
               work = false;
    }
}


int main(int argc, char **argv)
{
    bool work = true;
    num = 10;
    int numRanks, myRank, provided;
    MPI_Init_thread(&argc, &argv, MPI_THREAD_FUNNELED, &provided);
    MPI_Comm_size(MPI_COMM_WORLD, &numRanks);
    MPI_Comm_rank(MPI_COMM_WORLD, &myRank);

    std::thread workThread (WorkerFunction);
    //print statement 1
    cerr<<"Rank "<<myRank<<" Started workThread \n";

     int mult = 0;
     while(work)
     {
          mult += mult * num;
         //print statement 2
         if(myRank == 0) cerr<<"num = "<<num<<"\n";
         if(num == 1)
           work = false;
      }
   if(work == false)
      workThread.join();

   //print statement 3
   cerr<<"Rank "<<myRank<<" Done with both threads \n";

   MPI_Finalize();

 };

这是我在打印语句 2 时得到的输出

mpirun -np 4 ./Testing
Rank 0 Started workThread 
num = 10
num = 10
num = 10
num = 10
num = 10
num = 10
num = 10
num = 10
num = 10
num = 10
num = 10
num = 10
num = 10
Rank 1 Started workThread 
Rank 0 Done with both threads 
Rank 1 Done with both threads 
Rank 2 Started workThread 
Rank 3 Started workThread 
Rank 2 Done with both threads 
Rank 3 Done with both threads

如果我注释掉那个打印语句,那么它会进入一个无限循环,这就是我得到的输出

mpirun -np 4 ./Testing
Rank 0 Started workThread 
Rank 0 Done with both threads 
Rank 1 Started workThread 
Rank 2 Started workThread 
Rank 3 Started workThread 
Rank 2 Done with both threads 
Rank 3 Done with both threads

我不确定我做错了什么,感谢任何帮助。

【问题讨论】:

  • 您的代码有未定义的行为。当您访问num 时,您也需要在主函数中使用互斥锁。
  • @GillesGouaillardet.— volatile 在 C++ 标准中的线程中没有任何作用。一些编译器可能会对它做一些特殊的事情,但这将是一个扩展,而不是标准。
  • 附带说明,MPI 与此无关,在非 MPI 程序中也可以观察到相同的行为。
  • @GillesGouaillardet — 我说volatile 在 C++ 标准中在线程中 没有作用,并不是说它在 C++ 中根本没有作用。
  • @GillesGouaillardet — 如果您的编译器证明它可以使用volatile 完成您想要的操作,那么它应该可以与该编译器一起使用。尽管如此,volatile 并不是可移植代码中线程同步问题的正确答案,而对于您的问题“您不必声明 volatile int num;”的答案是“否”。已经给出了正确的解决方法:正确使用互斥锁。

标签: c++ multithreading mpi infinite-loop stdthread


【解决方案1】:

关于 MPI,我没有任何经验。 (我几十年前就用过,我敢肯定这个事实完全没有价值。)但是,OP 声称

我有一个简单的 C++ 代码来测试和理解线程。

考虑到多处理(MPI)和多线程(std::thread)本身就是很复杂的话题,我会先把这些话题分开,然后在获得一些经验后尝试将它们放在一起其中。

所以,我详细介绍一下多线程(我觉得可以)。


第一个示例是 OPs 代码的修订版本(所有对 MPI 的引用已删除):

#include <iostream>
#include <thread>
#include <mutex>
#include <chrono>

std::mutex mtxNum;
int num;

const std::chrono::milliseconds delay(100);

void WorkerFunction()
{
  for (bool work = true; work; std::this_thread::sleep_for(delay)) {
    int num_;
    mtxNum.lock();
    num_ = --num;
    mtxNum.unlock();
    work = num_ != 1;
  }
}

int main()
{
  num = 10;
  std::thread workThread(&WorkerFunction);
  int mult = 0;
  for (bool work = true; work; std::this_thread::sleep_for(delay)) {
    int num_;
    mtxNum.lock();
    num_ = num;
    mtxNum.unlock();
    std::cout << "num: " << num_ << '\n';
    mult += mult * num_;
    work = num_ != 1;
  }
  if (workThread.joinable()) workThread.join();
  std::cout << "Both threads done.\n";
}

输出:

num: 10
num: 8
num: 7
num: 6
num: 5
num: 4
num: 3
num: 2
num: 2
num: 1
Both threads done.

Live Demo on coliru

注意事项:

  1. 在多线程运行时,变量num是共享的,变量num在至少一个线程中被修改,每次访问都应该放入一个critical section(一对互斥锁和解锁)。

  2. 关键部分应始终保持尽可能短。 (一次只有一个线程可以通过临界区。因此,它引入了重新序列化,这会消耗并发性的加速。)我在每个线程中引入了一个局部变量 num_ 来复制共享变量的当前值并在相应线程的关键部分之后使用它。*

  3. 我为两个线程添加了sleep_for() 以便更好地说明。没有,我得到了

    num: 10
    num: 1
    Both threads done.
    

    我觉得有点无聊。

  4. 输出跳过num == 9 并打印num == 2 两次。 (这在其他运行中可能看起来不同。)原因是线程按照定义异步工作。 (两个线程中 100 毫秒的相等延迟不是可靠的同步。)如果没有任何东西(例如锁定的互斥锁)阻止,操作系统负责唤醒线程。可以随时暂停线程。

关于mtxNum.lock()/mtxNum.unlock():假设临界区包含比简单的--num; 更复杂的东西,这可能会引发异常。如果抛出异常,则会跳过mtxNum.unlock(),并生成deadlock,阻止任何线程继续进行。

为此,std 库提供了一个不错且方便的工具:std::lock_guard:

#include <iostream>
#include <thread>
#include <mutex>
#include <chrono>

std::mutex mtxNum;
int num;

const std::chrono::milliseconds delay(100);

void WorkerFunction()
{
  for (bool work = true; work; std::this_thread::sleep_for(delay)) {
    int num_;
    { std::lock_guard<std::mutex> lock(mtxNum); // does the mtxNum.lock()
      num_ = --num;
    } // destructor of lock does the mtxNum.unlock()
    work = num_ != 1;
  }
}

int main()
{
  num = 10;
  std::thread workThread(&WorkerFunction);
  int mult = 0;
  for (bool work = true; work; std::this_thread::sleep_for(delay)) {
    int num_;
    { std::lock_guard<std::mutex> lock(mtxNum); // does the mtxNum.lock()
      num_ = num;
    } // destructor of lock does the mtxNum.unlock()
    std::cout << "num: " << num_ << '\n';
    mult += mult * num_;
    work = num_ != 1;
  }
  if (workThread.joinable()) workThread.join();
  std::cout << "Both threads done.\n";
}

输出:

num: 10
num: 8
num: 7
num: 6
num: 5
num: 4
num: 3
num: 2
num: 1
Both threads done.

Live Demo on coliru

std::lock_guard 的诀窍在于,无论在任何情况下,析构函数都会解锁互斥锁,即使在临界区内抛出异常也是如此。

可能是,我有点偏执,但让我很恼火的是,对共享变量的非受保护访问可能是偶然发生的,而不会在任何调试会话或任何编译器诊断中被注意到。**因此,可能值得将共享变量隐藏到一个只能通过锁定它才能访问的类中。为此,我在示例中引入了Shared:

#include <iostream>
#include <thread>
#include <mutex>
#include <chrono>

template <typename T>
class Shared {
  public:
    struct Lock {
      Shared &shared;
      std::lock_guard<std::mutex> lock;
      Lock(Shared &shared): shared(shared), lock(shared._mtx) { }
      ~Lock() = default;
      Lock(const Lock&) = delete;
      Lock& operator=(const Lock&) = delete;

      const T& get() const { return shared._value; }
      T& get() { return shared._value; }
    };
  private:
    std::mutex _mtx;
    T _value;
  public:
    Shared() = default;
    explicit Shared(T &&value): _value(std::move(value)) { }
    ~Shared() = default;
    Shared(const Shared&) = delete;
    Shared& operator=(const Shared&) = delete;
};

typedef Shared<int> SharedInt;
SharedInt shNum(10);

const std::chrono::milliseconds delay(100);

void WorkerFunction()
{
  for (bool work = true; work; std::this_thread::sleep_for(delay)) {
    int num_;
    { SharedInt::Lock lock(shNum);
      num_ = --lock.get();
    }
    work = num_ != 1;
  }
}

int main()
{
  std::thread workThread(&WorkerFunction);
  int mult = 0;
  for (bool work = true; work; std::this_thread::sleep_for(delay)) {
    int num_;
    { const SharedInt::Lock lock(shNum);
      num_ = lock.get();
    }
    std::cout << "num: " << num_ << '\n';
    mult += mult * num_;
    work = num_ != 1;
  }
  if (workThread.joinable()) workThread.join();
  std::cout << "Both threads done.\n";
}

输出:与之前类似。

Live Demo on coliru

诀窍是可以从Shared::Lock 实例中检索对共享值的引用 → 即在它被锁定时。即使存储了引用:

    { SharedInt::Lock lock(shNum);
      int &num = lock.get();
      num_ = --num;
    }

int &amp;num 的生命周期在 SharedInt::Lock lock(shNum); 的生命周期之前结束。

当然,可以获取指向num 的指针以在范围之外使用它,但我认为这是破坏行为。


还有一点,我想提的是std::atomic:

原子库为细粒度的原子操作提供组件,允许无锁并发编程。对于涉及同一对象的任何其他原子操作,每个原子操作都是不可分割的。

虽然互斥锁可能是操作系统内核函数的对象,但原子访问可能会利用 CPU 功能完成,而无需进入内核。 (这可能会提高速度并减少对操作系统资源的使用。)

如果没有对相应的硬件支持,那就更好了。可用类型它回退到基于互斥锁或其他锁定操作的实现(根据std::atomic&lt;T&gt;::is_lock_free()中的注释):

除了 std::atomic_flag 之外的所有原子类型都可以使用互斥锁或其他锁定操作来实现,而不是使用无锁原子 CPU 指令。原子类型有时也可以是无锁的,例如如果在给定架构上只有对齐的内存访问自然是原子的,那么相同类型的未对齐对象必须使用锁。

带有std::atomic的修改示例:

#include <iostream>
#include <thread>
#include <atomic>
#include <chrono>

std::atomic<int> num;

const std::chrono::milliseconds delay(100);

void WorkerFunction()
{
  for (bool work = true; work; std::this_thread::sleep_for(delay)) {
    work = --num != 1;
  }
}

int main()
{
  num = 10;
  std::thread workThread(&WorkerFunction);
  int mult = 0;
  for (bool work = true; work; std::this_thread::sleep_for(delay)) {
    const int num_ = num;
    std::cout << "num: " << num_ << '\n';
    mult += mult * num_;
    work = num_ != 1;
  }
  if (workThread.joinable()) workThread.join();
  std::cout << "Both threads done.\n";
}

输出:

num: 10
num: 8
num: 7
num: 7
num: 5
num: 4
num: 3
num: 3
num: 1
Both threads done.

Live Demo on coliru


* 我为WorkingThread() 沉思了一会儿。如果它是修改num 的唯一线程,那么在临界区之外对num(在WorkingThread() 中)的读取访问应该是安全的——我相信。但是,至少,为了可维护性,我不会这样做。

**根据我的个人经验,此类错误很少(或从不)在调试会话中发生,而是在向客户演示的前 180 秒内发生。

【讨论】:

  • 非常感谢您的详细解答!!这非常有用,这正是我想要了解的线程。再次感谢!!
猜你喜欢
  • 2017-03-15
  • 1970-01-01
  • 2012-12-18
  • 2014-08-24
  • 1970-01-01
  • 1970-01-01
  • 2020-04-07
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多