在C++多线程并发编程中,各个线程可以共享主线程全局变量,这既是优点,也是缺点。
我们不用特殊机制即可在线程间共享数据,同样意味着,每个线程修改共享数据都会影响所有线程,引起数据竞争,导致计算结果不确定。
因此我们需要了解互斥锁,条件变量等,用以梳理计算顺序,辅助程序逻辑,防止程序错误。
通过C++的互斥库
可以用 lock() 函数进行加锁,使得数据只能由持有锁的线程进行读写改动,用 unlock() 函数进行解锁,释放数据的读写权限。
当然,在C++中,更常见的则是利用 std::lock_guard
通过简单的示例,我们可以感受一下互斥锁的基本运用。
标准库的 list 是非线程安全的,我们不能保证添加数据的安全,也不能保证检查是否含有数据的安全。
因为这两个操作不能保证原子性,很可能在添加数据时,多个线程同时找到链表末尾 end ,并分别在 end 后添加数据,导致数据添加混乱,以及内存泄漏。
而检查是否含有数据 A,则可能在一个线程检查时,另一个线程删除 A 或增加数据 A,导致结果的错误。
#include
#include
#include
#include
#include
#include
std::list<int> someList;
std::mutex someMutex;
void addToList(int newValue)
{
std::lock_guard<std::mutex> guard(someMutex);
// c++17 std::scoped_lock guard(someMutex);
someList.push_back(newValue);
}
auto listContains(int valueToFind) -> bool
{
std::lock_guard<std::mutex> guard(someMutex);
return std::find(someList.begin(), someList.end(), valueToFind) !=
someList.end();
}
void addSome(const std::vector<int> &someValue)
{
for (const auto &i : someValue)
{
addToList(i);
fprintf(stdout, "Yes %d\n", i);
}
}
void checkSome(const std::vector<int> &someValue)
{
for (const auto &i : someValue)
{
if (!listContains(i))
{
fprintf(stdout, "No %d\n", i);
}
}
}
auto main() -> int
{
std::vector<int> data;
data.reserve(1000);
std::vector<int> chkdata;
chkdata.reserve(10);
for (int i = 0; i != 1000; ++i)
{
data.push_back(i);
}
for (int i = 0; i < 1000; i += 100)
{
chkdata.push_back(i);
}
std::thread thrd(addSome, data);
std::thread thrd2(checkSome, chkdata);
thrd.join();
thrd2.join();
return 0;
}
互斥锁的设计,只能保证持有锁的函数入口的调用,能够线程安全,但是如果其他线程通过访问没有锁的函数入口读写数据,则意味着数据保护的失效。
这本质上是程序设计的逻辑出现问题,会导致程序顺利执行,但结果和预期完全不同,一旦涉及较大的程序,则极难排查。
#include
#include
#include
#include
#include
#include
std::list<int> someList;
std::mutex someMutex;
void addToList(int newValue)
{
std::lock_guard<std::mutex> guard(someMutex);
// c++17 std::scoped_lock guard(someMutex);
someList.push_back(newValue);
}
auto listContains(int valueToFind) -> bool
{
std::lock_guard<std::mutex> guard(someMutex);
return std::find(someList.begin(), someList.end(), valueToFind) !=
someList.end();
}
void addSome(const std::vector<int> &someValue)
{
for (const auto &i : someValue)
{
addToList(i);
fprintf(stdout, "Yes %d\n", i);
}
}
void addSomeUnsafe(const std::vector<int> &someValue)
{
for (const auto &i : someValue)
{
someList.push_back(i);
fprintf(stdout, "Yes %d\n", i);
}
}
void checkSome(const std::vector<int> &someValue)
{
for (const auto &i : someValue)
{
if (!listContains(i))
{
fprintf(stdout, "No %d\n", i);
}
}
}
auto main() -> int
{
std::vector<int> data;
data.reserve(1000);
std::vector<int> chkdata;
chkdata.reserve(10);
for (int i = 0; i != 1000; ++i)
{
data.push_back(i);
}
for (int i = 0; i < 1000; i += 100)
{
chkdata.push_back(i);
}
std::thread thrd(addSome, data);
std::thread thrd2(checkSome, chkdata);
std::thread thrd3(addSomeUnsafe, data);
thrd.join();
thrd2.join();
thrd3.join();
return 0;
}
对于某些容器操作,比如栈容器,C++标准库提供的接口是天然存在条件竞争的。
比如出栈,第一步是判断栈非空,第二步是出栈操作,逻辑上应该是原子化的,但现实则并不是,如果用于多线程,需要进行相应的改造。
而改造也是需要动些脑筋的,虽然是一步原子操作,如何传出两个讯息,其一,是否为空,其二弹出一个数据。
我们可以用引用类型作为形参,取得弹出的数据,函数返回bool值,确定栈在弹出数据时是否为空,确定引用对象的赋值是否是有意义的赋值。
还有一种思路是函数返回指针,指针为空意味着弹出时栈为空,指针不空意味着栈弹出时不空,结果是有意义的。
#include
#include
#include
#include
#include
struct empty_stack : std::exception
{
auto what() const noexcept -> const char * override;
};
template <typename T>
struct threadsafe_stack
{
threadsafe_stack();
threadsafe_stack(const threadsafe_stack &);
auto operator=(const threadsafe_stack &) -> threadsafe_stack & = delete;
void push(T new_value);
auto pop() -> std::shared_ptr<T>;
auto pop(T &value) -> bool;
auto empty() const -> bool;
};
多线程访问读写数据共享数据很容易,但做对,得到想要的结果很难。
通过最简单的互斥锁,可以确保单一入口的函数对共享数据访问线程安全,然而一旦混合无锁入口,则线程就不安全了。
同时,设计线程安全的程序还需考虑排除固有的数据竞争,将需要原子化的部分合在一起,要记住,每一步都是线程安全的,组合起来则不一定是线程安全的,需要进行重新的设计。