一、线程互斥
1.1 线程间互斥的概念
在学习管道的时候,管道是自带同步与互斥的。而在线程中,当多个线程没有加锁的情况下同时访问临界资源时会发生混乱。在举例之前,先了解几个概念。
- 临界资源:多个线程执行流共享的资源叫做临界资源
- 临界区:每个线程内部访问临界资源的代码叫做临界区
- 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
- 原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完
成
1.2 互斥量
大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。多个线程并发的操作共享变量,会带来一些问题。
我们可以通过一个买票的例子,来看这块问题。
int ticket = 2000;
void *STicket(void *asg)
{
while (1)
{
if (ticket > 0)
{
usleep(100);
printf("%s sang ticket:%d \n", (char *)asg, ticket--);
}
else
{
break;
}
}
return NULL;
}
int main()
{
pthread_t t[4];
int i;
for (i = 0; i < 4; i++)
{
char *p = (char *)malloc(sizeof(char) * 64);
sprintf(p, "pthread t%d", i);
pthread_create(&t[i], NULL, STicket, (void *)p);
}
pthread_join(t[0], NULL);
pthread_join(t[1], NULL);
pthread_join(t[2], NULL);
pthread_join(t[3], NULL);
return 0;
}
我们在运行结果中可以看到,票的数量本不可能出现负数的,但是在结果中出现了,那么这就是一个问题。
多个线程并发的访问同一块临界资源,我们用t1,t2,t3,t4,来表示四个线程。一开始票的数量有1000张。
《出现问题1》当t1首先访问到票时,判断票还有剩余,于是拿走一张票,票还剩999张。但是这些线程是并发执行的,有可能多个线程同时拿到票,且通过对票进行减减操作,那么这个票是重复了。
《出现问题2》当t3拿到票的时候,刚准备对票进行减减,时间片就到了,线程退出,那么在t3这个线程内把读取到的票的数量保存起来,当t3这个线程有运行时,先恢复上下文数据,然后对山下文数据中保存票的数量进行减减,当t3这个线程完成了操作后,把剩余票的数量进行更新,那么在t3没有运行前,票已经抢完了,但是t3它不知道,然后又把票的数量进行更新了,票又回来了,这个时候又出错了。出现负数的情况就是这样。
在我们判断票是否有剩余的时候,和对票减减的时候,并不是具有原子性的,因为这个时候,其他线程也在进行抢票,可能拿到重复的票。我们可以通过汇编来验证是否具有原子性。
int main()
{
int a = 5;
a--;
return 0;
}
--操作并不是原子性,而是对应了三条汇编:
- load :将共享变量ticket从内存加载到寄存器中
- update : 更新寄存器里面的值,执行-1操作
- store :将新值,从寄存器写回共享变量ticket的内存地址
想要解决上面的问题,需要做到三点:
- 代码必须要有互斥行为:当一个线程的代码进入临界区执行时,不允许其他线程进入该临界区。
- 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区
- 果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
而以上的三点本质就是加一把锁,在Linux上提供的这把锁叫做互斥量。
先要理解这个锁。当多个线程同时要执行临界区的代码,那么谁先申请到这把锁,谁就执行,其他的线程就开始进行等待,等待这把锁被释放,然后申请这把锁。
1.3 互斥量的接口
初始化互斥量有两中方法:
- 方法1,静态分配
pthread_mutex_t mutex=PTHREAD_MUTEX_INITIALIZER - 方法2,动态分配
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
参数:mutex:要初始化的互斥量
attr:设置属性,一般设置NULL,用默认设置
返回值:成功返回0,错误返回错误号功能:销毁互斥量 原型:int pthread_mutex_destroy(pthread_mutex_t *mutex); 参数:mutex:要销毁的互斥量 返回值:成功返回0,错误返回错误号
【注意】
- 使用PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁。
- 不要销毁一个已经加锁的互斥量
- 已经销毁的互斥量,要确保后面不会有线程再尝试加锁
互斥量加锁和解锁
功能:加锁
原型:int pthread_mutex_lock(pthread_mutex_t *mutex);
参数:mutex:要加锁的互斥量
返回值:成功返回0,错误返回错误号
功能:解锁
原型:int pthread_mutex_unlock(pthread_mutex_t *mutex);
参数:mutex:要解锁的互斥量
返回值:成功返回0,错误返回错误号
调用pthread_mutex_lock会遇到的情况
- 互斥量处于没锁的状态,该函数将互斥量锁定,同时返回成功。
- 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
现在我们对之前的买票系统进行改进
int ticket = 2000;
pthread_mutex_t lock;
void *STicket(void *asg)
{
while (1)
{
// 在执行临界区的代码前,先申请锁(加锁)
pthread_mutex_lock(&lock);
if (ticket > 0)
{
usleep(100);
printf("%s sang ticket:%d \n", (char *)asg, ticket--);
}
else
{
// 当没有票的时候,也释放锁(解锁)
pthread_mutex_unlock(&lock);
break;
}
// 访问完了临界资源时,释放锁(解锁)
pthread_mutex_unlock(&lock);
}
return NULL;
}
int main()
{
// 动态的初始化锁
pthread_mutex_init(&lock, NULL);
pthread_t t[4];
int i;
for (i = 0; i < 4; i++)
{
char *p = (char *)malloc(sizeof(char) * 64);
sprintf(p, "pthread t%d", i);
pthread_create(&t[i], NULL, STicket, (void *)p);
}
pthread_join(t[0], NULL);
pthread_join(t[1], NULL);
pthread_join(t[2], NULL);
pthread_join(t[3], NULL);
// 最后销毁锁
pthread_mutex_destroy(&lock);
return 0;
}
1、一个线程拿到了锁,会不会被其他线程切换?
答:会被切换,当这个拿到锁的线程切换到了其他线程,其他线程依然没有锁,依然要等待,然而当拿到锁的线程又开始运行时,首先要先恢复上下文数据,这个线程依然是拿到锁的状态(这个线程是拿着锁被切走的),可以继续执行临界区的代码。
2、申请锁的过程是不是原子性的?
答:申请锁的原子性的,要么没有申请到锁,要么锁已经释放了,可以申请锁。
3、锁本身就是临界资源,那么谁来保护锁?
答:锁是来保护临界资源的,但是锁也是临界资源的呀。但是锁本身就具有原子性,申请锁的过程必须是原子性的。
1.4 可重入VS线程安全
概念
- 线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
- 重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
常见的线程不安全情况
- 不保护共享变量的函数
- 函数状态随着被调用,状态发生变化的函数
- 返回指向静态变量指针的函数
- 调用线程不安全函数的函数
常见的线程安全情况
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
- 类或者接口对于线程来说都是原子操作
- 多个线程之间的切换不会导致该接口的执行结果存在二义性
常见的不可重入情况
- 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
- 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
- 可重入函数体内使用了静态的数据结构
常见的可重入情况
- 不使用全局变量或静态变量
- 不使用用malloc或者new开辟出的空间
- 不调用不可重入函数
- 不返回静态或全局数据,所有数据都有函数的调用者提供
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
可重入与线程安全的联系
- 函数是可重入的,那就是线程安全的
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
- 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
可重入与线程安全的区别
- 可重入函数是线程安全函数的一种
- 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
- 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的
二、线程同步
在上面的买票系统中,如果线程1的优先级非常高,那么会不会出现票都被线程A给抢完了。线程1申请锁后抢票完成,释放锁,释放完后线程A又申请到锁,如此往复,直到票买完了。按理说这样没有错,各凭本事买票嘛,但这样没有高效的让多个执行流使用这个资源,那么多执行流就没有意义了。线程同步就是来解决这个问题的。要申请锁的所有线程依次排队申请,使用完锁的线程去队尾排队,这样就防止了一个优先级高的线程抢完所以资源。
2.1 同步概念和竞态条件
- 在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步
- 竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。在线程场景下,这种问题也不难理解
2.2 条件变量
条件变量我们可以理解为:条件变量使我们可以睡眠等待某种条件的出现。
饥饿问题:多个执行流,在保证互斥地访问同一块资源时,该资源一直被同一个执行流访问,就会导致其他执行流形成饥饿,这种现象就做饥饿问题。
初始化条件变量
功能:初始化条件变量
原型:int pthread_cond_init(pthread_cond_t *restrict cond,
const pthread_condattr_t *restrict attr);
参数:cond:要初始化的条件变量
attr:条件变量的属性,设置NULL,使用默认的。
销毁条件变量
功能:释放条件变量
原型:int pthread_cond_destroy(pthread_cond_t *cond);
参数:cond:要销毁的条件变量
等待条件满足
功能:等待条件满足
原型: int pthread_cond_wait(pthread_cond_t *restrict cond,
pthread_mutex_t *restrict mutex);
参数:cond:要在这个条件变量上等待
mutex:互斥量,后面详细解释
唤醒等待
功能:唤醒等待队列中队头线程
原型:int pthread_cond_signal(pthread_cond_t *cond);
参数:cond:在这个条件变量上唤醒
功能:唤醒所以线程
原型 :int pthread_cond_broadcast(pthread_cond_t *cond);
参数:cond:在这个条件变量上唤醒
返回值:成功返回0;失败返回错误号
pthread_cond_t
我们设置条件变量的类型是pthread_cond_t。
struct pthread_cond_t
{
int flag;//0表示没有钥匙,1表示有钥匙
task_struct *queue;//等待队列
}
简单的案例:
// 定义锁
pthread_mutex_t lock;
// 定义条件变量
pthread_cond_t cond;
// 设置票的数量为6张
int ticket = 6;
void *RunRoute(void *arg)
{
// 分离自己,线程退出自动释放
pthread_detach(pthread_self());
while (true)
{
// 申请锁
pthread_mutex_lock(&lock);
// 等待条件变量
pthread_cond_wait(&cond, &lock);
if (ticket > 0)
{
std::cout << (char *)arg << "抢到了" << ticket << "号票" << std::endl;
ticket--;
}
else
{
std::cout << "票卖完了" << std::endl;
// 释放锁
pthread_mutex_unlock(&lock);
break;
}
// 释放锁
pthread_mutex_unlock(&lock);
}
}
int main()
{
// 初始化锁
pthread_mutex_init(&lock, nullptr);
// 初始化条件变量
pthread_cond_init(&cond, nullptr);
pthread_t t1, t2, t3;
// 创建线程
pthread_create(&t1, NULL, RunRoute, (void *)"thread t1");
pthread_create(&t2, NULL, RunRoute, (void *)"thread t2");
pthread_create(&t3, NULL, RunRoute, (void *)"thread t3");
// 主线程控制其他线程
while (true)
{
// 通过回车来唤醒等待的线程
getchar();
// 唤醒等待队列中队头的线程
pthread_cond_signal(&cond);
}
return 0;
}
看运行结果,t1、t2、t3线程轮流抢票。
pthread_cond_wait为什么需要互斥量
看上面的代码。在pthread_cond_wait函数最后一个参数是互斥量。
看代码,当一个线程申请到锁时,就开始执行pthread_cond_wait进行等待,在等待的过程中,该线程的锁会被释放,等线程被唤醒的时候,该线程的锁又会回到手上。
- 条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所以必须要有一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足,并且友好的通知等待在条件变量上的线程。
- 条件不会无缘无故的突然变得满足了,必然会牵扯到共享数据的变化。所以一定要用互斥锁来保护。没有互斥锁就无法安全的获取和修改共享数据。
条件变量使用规范
- 等待条件代码
pthread_mutex_lock(&lock);
while (条件为假)
pthread_cond_wait(&cond, &lock);
//修改条件
pthread_mutex_unlock(&mutex);
- 给条件发送信号代码
pthread_mutex_lock(&lock);
//设置条件为真
pthread_cond_signal(&cond);
pthread_mutex_unlock(&lock);
三、死锁
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。
3.1 死锁的四个必要条件
- 互斥条件:一个资源每次只能被一个执行流使用
- 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
- 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
- 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
自己也可以让自己死锁。
pthread_mutex_t lock;
int count = 10;
void *Rounit(void *asg)
{
while (1)
{
pthread_mutex_lock(&lock);
pthread_mutex_lock(&lock);
if (count > 0)
{
count--;
}
else
{
pthread_mutex_unlock(&lock);
break;
}
pthread_mutex_unlock(&lock);
}
}
int main()
{
pthread_t t1;
pthread_mutex_init(&lock, NULL);
pthread_create(&t1, NULL, Rounit, NULL);
pthread_join(t1, NULL);
pthread_mutex_destroy(&lock);
return 0;
}
这会行成死锁,因为线程申请了两次锁,第二次申请锁的时候锁已经在你手上了,但因为你还要等待锁被释放,所以一直等待,形成了死锁。
3.2 避免死锁
- 破坏死锁的四个必要条件
- 加锁顺序一致
- 避免锁未释放的场景
- 资源一次性分配
3.3 避免死锁的算法
- 死锁检测算法
- 银行家算法
四、POSIX信号量
POSIX信号量和SystemV信号量作用相同,都是用于同步操作,达到无冲突的访问共享资源目的。 但POSIX可以用于线程间同步。
使用信号量要包semaphore.h头文件
功能:初始化信号量
原型:int sem_init(sem_t *sem, int pshared, unsigned int value);
参数:sem:信号量
pshared:0表示线程间共享,非零表示进程间共享
value:信号量初始值
功能:销毁信号量
原型:int sem_destroy(sem_t *sem);
参数:sem:信号量
功能:等待信号量,会将信号量的值减1(P操作)
原型:int sem_wait(sem_t *sem);
参数:sem:信号量
功能:发布信号量,表示资源使用完毕,可以归还资源了。将信号量值加1(V操作)
原型:int sem_post(sem_t *sem);
参数:sem:信号量
写个二元信号量来熟悉这些函数。二元信号量的使用类似锁。
二元信号量: value为1,当 value经过P操作变成0时,线程要等待 value又变成1。
class Sem
{
public:
Sem()
{
sem_init(&sem, 0, 1);
}
~Sem()
{
sem_destroy(&sem);
}
void P()
{
sem_wait(&sem);
}
void V()
{
sem_post(&sem);
}
private:
sem_t sem;
};
Sem sem;
int tickets = 10;
void *GetTickets(void *asg)
{
while (true)
{
sleep(1);
sem.P();
if (tickets > 0)
{
std::cout << (char *)asg << "抢到了" << tickets << "号票" << std::endl;
tickets--;
sem.V();
}
else
{
break;
}
}
}
int main()
{
pthread_t t1, t2;
pthread_create(&t1, nullptr, GetTickets, (void *)"thread 1");
pthread_create(&t2, nullptr, GetTickets, (void *)"thread 2");
pthread_join(t1, nullptr);
pthread_join(t2, nullptr);
return 0;
}