大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。多个线程并发的操作共享变量,会带来一些问题。
例如下面我们模拟一个多线程抢票的程序。使用一个全局变量 ticket 表示票的数量,创建多个线程进行抢票,代码如下:
#define NUM 5
int ticket = 100;
class threadData
{
public:
threadData(int number)
{
threadname = "thread-" + to_string(number);
}
public:
string threadname;
};
void* getTicket(void* args)
{
threadData *td = static_cast(args);
while(1)
{
if(ticket > 0)
{
usleep(1000); // 模拟抢票的时间
printf("%s is running, get a ticket: %d\n", td->threadname.c_str(), ticket);
ticket--;
}
else
break;
}
return nullptr;
}
int main()
{
vector tids;
vector thread_datas;
for(int i = 1; i <= NUM; i++)
{
threadData* td = new threadData(i);
pthread_t tid;
pthread_create(&tid, nullptr, getTicket, td);
tids.push_back(tid);
thread_datas.push_back(td);
}
for(auto e : tids)
pthread_join(e, nullptr);
for(auto td : thread_datas)
delete td;
return 0;
}
我们运行起来之后,会看到线程抢到了负数的票!
为什么会出现这种情况呢?这种情况我们称为共享数据在无保护的情况下,被多线程并发访问,造成了数据不一致问题!所以对于一个全局变量进行多线程并发减减或者加加,不是安全的!下面我们来分析一下。
首先需要对 ticket- -,先要将 ticket 读入到 CPU 的寄存器中,然后在 CPU 中要进行计算操作,最后再将 ticket 数据写回内存中。至此就完成了一次 ticket- -,所以上面三个步骤,都会对应每一个汇编语句。
那么假设我们现在有两个线程,分别为线程1和线程2,在线程执行的代码间隙中,线程是随时有可能会被切换的!而线程在执行的时候,将共享数据加载到 CPU 寄存器的本质就是把数据的内容变成了自己上下文的内容!也就是以拷贝的方式给自己单独拿了一份!
那么如果线程1刚好读取到内存中的数据,假设此时数据还是100,此时它要被切换了,那么它就要把自己上下文数据保存起来,而保存上下文的本质就是以拷贝的方式,给自己单独拿了一份!那么此时线程1就把100保存到自己的上下文中了。
接下来线程2就开始抢票了,此时线程2在它的时间片内已经抢了99张票了!此时内存中只剩下一张票!
那么当线程2切换后,线程1继续拿着它的上下文数据放回CPU中计算,注意,此时线程1中的 ticket 还是100,那么计算完后为99,再将 99 写回内存中!此时就导致了 ticket 的数据不一致问题!所以 ticket- - 操作是不安全的!也就是它不具备原子性!
另外,我们不仅仅在对 ticket- -,这种叫做数值计算,而且还在对 ticket 做判断是否大于0,这个过程也是在对 ticket 计算,这种叫做逻辑运算!所以,假设当前 ticket 为1了,在判断期间,可能会有多个线程在进行判断!因为一个线程在判断的期间有可能会被切走!此时它们每一个线程的上下文中都认为 ticket 是1,所以会将 ticket 减到负数!而且判断完毕之后,ticket 就不会被用了,在计算 ticket- - 的时候要重新到内存中读取数据!
那么这个问题要怎么解决呢?对于共享数据的访问,需要保证任何时候只有一个执行流访问,这就是互斥!所以我们需要通过互斥的方式来解决,也就是互斥锁!接下来我们就开始学习互斥锁。
在 Linux 中,pthread 库给我们提供了一种互斥锁解决上面多线程访问共享数据不一致的问题。接下来我i们认识一下互斥锁的相关接口:
pthread_mutex_init()
int pthread_mutex_init(pthread_mutex_t *restrict mutex,
const pthread_mutexattr_t *restrict attr);
该接口是对一把锁初始化。我们可以看到第一个参数的类型是 pthread_mutex_t,这是库给我们提供的一种数据类型!第一个参数是输入型参数,我们定义一个 pthread_mutex_t 类型的锁传入它的地址即可;第二个参数代表这把锁的属性,我们也不管,设置为 nullptr 即可。
其实,初始化一把锁有两种方式,以上是一种方式,下面还有一种方式是定义一把全局的锁,如果我们使用下面的方法定义了一把锁,就不需要使用上面的方式了;而且也不用释放这把锁了,但是释放也没有问题。其中定义全局锁是固定的,如下:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_destroy()
int pthread_mutex_destroy(pthread_mutex_t *mutex);
该接口是释放一把锁。第一个参数和初始化时的第一个参数一样。注意如果我们使用 pthread_mutex_init() 的方式初始化一把锁,必须要使用 pthread_mutex_destroy() 进行释放;但是使用全局锁就可以不用释放。
该接口就是对一把锁进行加锁:
int pthread_mutex_lock(pthread_mutex_t *mutex);
该接口就是对一把锁进行解锁:
int pthread_mutex_unlock(pthread_mutex_t *mutex);
下面我们就使用上面抢票的代码进行加锁,对 ticket 加锁。根据我们以前的知识,这个 ticket 就是临界资源,而临界资源并不是全部代码都在访问,而是只有一小部分在访问,我们就把这一小部分的代码的区域称为临界区。其实加锁的本质是用时间来换取安全,加锁的表现就是线程对于临界区代码需要串行执行,也就是类似于排队,所以加锁的原则就是尽量要保证临界区代码越少越好!所以上述的代码中,临界区应该是对 ticket 进行访问的区域!如下代码:
首先我们在类中定义一把锁,方便每个线程都有自己的锁:
class threadData
{
public:
threadData(int number, pthread_mutex_t* lock)
:_lock(lock)
{
_threadname = "thread-" + to_string(number);
}
public:
string _threadname;
pthread_mutex_t* _lock;
};
接下来我们在主函数中定义一把锁,注意,这里定义的锁,是在 main() 函数的栈帧中的,也就是主线程中的,由于我们抢票的程序也在主函数中,所以这样定义不会有问题;最后在主函数返回前释放锁,代码如下:
int main()
{
pthread_mutex_t lock;
pthread_mutex_init(&lock, nullptr);
vector tids;
vector thread_datas;
for(int i = 1; i <= NUM; i++)
{
threadData* td = new threadData(i, &lock);
pthread_t tid;
pthread_create(&tid, nullptr, getTicket, td);
tids.push_back(tid);
thread_datas.push_back(td);
}
for(auto e : tids)
pthread_join(e, nullptr);
for(auto td : thread_datas)
delete td;
pthread_mutex_destroy(&lock);
return 0;
}
接下来是抢票的程序:
void* getTicket(void* args)
{
threadData *td = static_cast(args);
while(1)
{
pthread_mutex_lock(td->_lock);
if(ticket > 0)
{
usleep(1000); // 模拟抢票的时间
printf("%s is running, get a ticket: %d\n", td->_threadname.c_str(), ticket);
ticket--;
pthread_mutex_unlock(td->_lock);
usleep(10);
}
else
{
pthread_mutex_unlock(td->_lock);
break;
}
}
cout << td->_threadname.c_str() << " quit!" << endl;
return nullptr;
}
如上代码,在加锁和解锁锁的区域中,就称为临界区。其中,执行流在申请锁的时候,如果申请锁成功,才能往后执行后面的代码,如果不成功,就会阻塞等待!
执行的结果如下:
我们可以看到,ticket 不会出现 0 和负数的情况了,也就是说,临界资源被并发访问导致数据不一致问题已经解决了!
但是代码中有些细节我们还需要讲解一下。
在抢票的程序中,我们可以看到,在一个线程抢完票后,解锁后,我们在其后面加了一句 usleep(10);
,这是什么意思呢?很简单,当一个线程加锁后,其它线程就被阻塞等待挂起了,那么当该线程解锁时,其它线程还没来得及从阻塞状态转为运行状态,该线程又去申请锁了,也就是说,唤醒线程的成本更大;而且,我们抢完票后还有后续的代码需要执行,比如处理票的后续动作,这里我们就没有实现。也就是说,所以我们在一个线程解锁后,加上短暂的休眠时间,一是为了有时间唤醒其它线程,二是为了模拟抢票后的后续动作。
如果我们没有加上 usleep(10);
这句代码,那么该线程就会一直占用这把锁,所以导致票就被它抢完了。那么也就是说,这种纯互斥环境,如果锁分配不够合理,容易导致其它线程的饥饿问题!但是不是说只要有互斥,必有饥饿,而是适合纯互斥的场景,就用互斥!
新来的线程,必须要从等待队列的最后开始排队;解锁的线程,不能马上重新申请锁,必须也要从等待队列的最后开始排队。这就可以让所有的线程获取钥匙,按照一定的顺序,这种按照一定顺序性获取资源的称为同步,这个我们后面详谈。
每一个线程进入临界区访问临界资源的时候,首先需要申请加锁,所以锁本身就是共享资源,也就是临界资源!所以申请加锁和解锁本身就被设计为原子性的操作了!如何做到的呢?我们后面讲原理再谈。
那么在临界区中,线程可以被切换吗?可以切换!因为在线程被切出去的时候,是持有锁被切走的,所以在该线程被切换的时候,其他线程也不能进临界区访问临界资源,因为锁只有一把!所以对于其它线程来说,一个线程要么没有锁,要么释放锁,当前线程访问临界区的过程,对于其它线程是原子的!
我们已经知道,ticket- - 不是原子的,因为这个操作会被分为三个汇编语句,那么什么是原子的呢?在计算机底层,我们认为,一条汇编语句就是原子的!
为了实现互斥锁操作,大多数体系结构都提供了 swap 或 exchange 指令,该指令的作用是把寄存器和内存单元的数据交换,由于只有一条汇编指令,保证了原子性。现在我们把 lock 和 unlock 的伪代码演示一下:
首先 movb
就是把 0 写入 al 寄存器,可以理解为 eax 寄存器:
接下来 xchgb
就是将 al 寄存器中的内容和内存中定义的一个变量 mutex 进行交换,其中 mutex 的初始值为1:
接下来对 al 寄存器中的值进行判断,如果大于 0,说明申请加锁成功,否则申请加锁失败,挂起等待。
上面我们演示的都是一个线程来申请加锁,如果有两个线程来申请加锁呢?例如,线程1和线程2来申请加锁,而加锁的语句是一句,但是它被分为上面多个汇编语句,所以当一个线程执行到某一个汇编语句的时候,随时都有可能被切换!
假设线程1申请加锁的过程中,刚刚执行完第一步,即将 0 写入了 al 寄存器中,实际上是写入线程的硬件的上下文中。此时线程2来了,线程1要被切走,所以线程1将 al 寄存器中的内容保存起来,即将 0 保存起来,当切换回来的时候执行 xchgb 语句。
线程2来的时候,再次将0写入 al 寄存器中,然后执行xchgb语句,将 al 寄存器中的内容和内存的内容交换,交换完成后,al 寄存器中的内容变成1,线程2中的上下文内容也变成1,正常来说线程2此时做判断,此时al寄存器的值大于0,所以可以直接返回。
但是如果在线程2做判断的时候,线程2需要被切走,线程1切回来,首先先要将上下文恢复回来,此时将 al 寄存器中的内容恢复成为0,然后和内存中的值交换,交换完后发现 al 寄存器中的值为 0,此时线程1就被挂起等待了。
线程1被挂起等待后不会被调度,所以此时线程2被切回来,恢复上下文,把1放回al寄存器中,然后做判断,大于0,申请加锁成功,返回0。
所以从上面的过程我们可以看出,其实 xchgb 的语句最重要。交换的本质就是把内存中的数据,交换到CPU的寄存器中,也就是将数据交换到线程的上下文中!而线程上下文是线程私有的!另外,内存中的数据是被所有线程共享的,而锁只有一把,所以申请加锁的本质就是把一把共享的锁,让一个线程以一条汇编的方式,交换到自己的上下文中,就代表当前线程持有锁了!
那么解锁的汇编语句如下:
其实就是将内存中的数据重置为1即可。并没有将上一次线程申请加锁的 1 交换回内存中,因为并不需要,因为每一个线程在申请加锁的时候首先需要将 0 写入 al 寄存器中,也就是写入自己的硬件上下文中,此时就相当于将原来申请过加锁的 1 覆盖掉。
也就是说,如果一个函数不可重入,那么在多线程执行时,可能会出现线程安全问题。如果一个函数可被重入的,那么就一定不会出现线程安全问题。
最后总结就四个结论:
死锁是指在一组执行流中的一个线程持有一把锁,另一个线程持有另一把锁,但因互相申请对方的锁,并不释放自己的锁而处于的一种永久等待状态。
首先我们了解一下什么叫做死锁的必要条件,也就是只要产生了死锁,必定所有的条件都要满足。也就是以下四个条件都要满足:
其实我们除了 pthread_mutex_lock() 加锁之外,还有 pthread_mutex_trylock(),也就是申请锁失败会立即返回,不会阻塞等待。所以我们申请锁的时候也可以使用 pthread_mutex_trylock() 避免死锁,也就是破环请求与保持条件。