如何编写线程安全代码
很多同学在面对多线程代码时都会望而生畏,那么我们应该如何编写多线程代码呢 ?今天这篇小结,我们主要从如下两个方面思考:
- 线程私有资源,没有线程安全问题。
- 共享资源,线程间以某种秩序使用共享资源也能实现线程安全。
本文围绕上述两个核心点来讲解,聊聊编程中线程安全性问题。
什么是线程安全
我们说一段代码是线程安全的,而且仅当我们在多个线程中同时且多次调用这段代码都能给出正确的结果,这样的代码我们才说是线程安全代码(Thread Safety)否则就不是线程安全代码(thread unsafe)。
举个例子:💚💚💚
int func()
{
int a = 1;
int b = 1;
return a+b;
}
对于上述代码,无论你用多少线程同时调用,怎么调用,什么时候调用都会返回 整型 2,这段代码就是线程安全的。
那么我们应该如何写出线程安全的代码 ?这个时候,你需要识别什么是私有资源,什么是公共资源,即你需要识别线程的私有资源和共享资源都有哪些,这是解决线程安全问题的核心所在。
线程私有资源
线程的本质就是函数的执行,函数运行时信息保存在: 栈区。
💚💚💚
每个线程都有一个私有的栈区,因此在栈上分配的局部变量就是线程私有的,无论我们怎么使用这些局部变量都不管其它线程什么事。
线程间共享数据
线程间的共享区域包括:
- 用于动态分配内存的堆区,我们用 C++中 malloc 或者 new ,就是在堆区上申请的内存
- 全局区,这里存放的就是全局变量。
- 文件,线程共享进程打开的文件。
好了,现在你已经知道了哪些是线程私有的,哪些是线程共享的,接下来就简单了。
值得注意的是,关于线程安全的一切问题全部围绕着线程私有数据与线程共享数据来处理,抓住了线程私有资源和共享资源 这个主要矛盾也就抓住了解决线程安全问题的核心。
下面以C++代码为例,但是这里讲解的方法同样适用于任何语言。
只使用线程私有资源
int func()
{
int a = 1;
int b = 1;
return a+b;
}
这样的代码,无论在多少个线程中怎么调用什么时候调用,func 函数都是确定的 返回2 ,该函数不依赖任何全局变量,不依赖任何参数,且使用局部变量都是线程私有资源,这样的代码也被称为无状态函数,很显然这样的代码是线程安全的,请放心大胆的使用。
线程私有资源+函数参数
按值传参
如果你传入的参数的方式是:按值传入,那么这是没问题的,代码依然是线程安全的.
按值传参:参数保留在栈区,任然是线程的私有资源。
int func(int num)
{
num++;
return num;
}
按引用传参
int func(int* num)
{
++(*num);
return *num;
}
引用传参数还是分两种情况:
- 如果调用该函数的线程传入的参数是线程私有资源,那么该函数依然是线程安全的
- 如果传入的参数是全局变量,那么此时函数将不再是线程安全的代码。
我们重点分析下第二种情况:
int global_num = 1;
int func(int* num)
{
++(*num);
return *num;
}
// 线程1
void thread1()
{
func(&global_nmu);
}
// 线程2
void thread2()
{
func(&global_num);
}
很显然:全局变量存在于数据区,它属于共享资源,而且此变量又是可修改的,func 函数不再是线程安全的,此时对全局变量的修改必须加锁保护。
线程局部存储
上一节说到,由于全局变量存在于数据区,变量又是可读可修改的,这样会造成线程不安全,本章就通过:线程局部存储方式来对变量修改加以保护。
💚💚💚
敲黑板:被 __thread关键词修饰过的变量放在了线程私有存储中(thread local storage),即:
- global_num : 是全局变量
- global_num : 是线程私有的
__thread int global_num = 100;
int func()
{
++global_num;
return global_num;
}
各个线程对 global_num 的修改不会影响到其他线程,因为是线程私有资源,因此 func 函数是线程安全的。
函数返回值
这里也有两种情况,一种是函数返回的是值, 另一种返回对变量的引用。
1. 返回的是值
int func()
{
int a = 100;
return a;
}
毫无疑问,这段代码是安全的,无论我们怎样调用该函数,都会返回确定的值是 100.
2. 返回的是引用
int* func()
{
static int a = 100;
return &a;
}
上述代码中,在 func 函数定义 一个 静态局部变量,我们知道,静态变量(包括静态局部变量)是存储在数据区的,而数据区又是线程共享资源,那就很显然了此代码不是线程安全的。
下面我们写个简单的程序证明一下:局部静态变量存储在数据区。
#include<iostream>
void func()
{
int a = 0;
static int b = 0;
a++;
b++;
std::cout << "a = " <<a << " b= " << b << std::endl;
}
int main()
{
for(int i = 0; i<5; i++)
{
func();
}
return 0;
}
很显然,上面的程序证明 静态局部变量 b, 并没有随之 func函数出栈而销毁,所以静态局部变量b 也并没有存储在栈区。
如何调用非线程安全代码
💚💚💚
函数调用前加 锁
func 函数是非线程安全的,但是我们在调用该函数前加了一把锁 进行保护,那么 这时 funcA 函数就是线程安全的 。(它的本质就是我们用一把锁间接的保护了全局变量)。
int global_num = 0;
int func()
{
++global_num;
return global_num;
}
int funcA()
{
mutex lock;
locl.lock();
func();
locl.unlock();
}
传入线程私有的局部变量
如果传入的是:线程私有的局部变量,那么此时 funcA 函数依然是 线程安全的,无论多少线程调用 funcA 都不会干扰到 func 线程
int func(int *num)
{
++(*num);
return *num;
}
void funcA()
{
int a = 100;
func(&a);
}
总结:如何实现线程安全
其实从上面分析的情况来看,实现线程安全无外乎围绕线程私有资源和线程共享资源这两点:你需要识别出哪些是线程私有的, 哪些是线程共享资源,这是核心。然后对症下药即可。
- 不使用任何全局变量 : 只使用线程私有资源,这种通常被称为 无状态代码
- 线程局部存储: 如果要使用全局资源,是否可以声明为线程局部存储(使用 __thrad 关键字修饰),因为这种变量虽然是全局的,但是每个线程都有一个属于自己的副本,对其修改不会影响到其它线程。
- 只读:如果必须使用全局资源,那么全局资源是否可以是只读,多线程使用只读的全局资源 不会有线程安全问题。
- 原子操作: 原子操作是指:在执行过程总不可能被其它线程打断,像 C++ 中的 std::atomic 修饰过的变量,对这类变量的操作无需传统的加锁保护,**因为C++ 会确保在变量的修改过程中不会被打断,我们通常说的各种无锁数据结构就是在这类原子操作的基础上构建的。
- 同步互斥 :