十、多线程中线程间的"独立"
1.线程在代码段通过执行不同的函数,实现代码段的独立;
2.新线程通过在共享区划分不同的管理属性和不同的栈空间,实现栈的独立,而主线程使用的是栈空间;
3.线程通过获取到不同的堆空间地址,实现堆的独立;
总结:线程是通过对共享资源的划分来实现资源的独立的,但是只要将其他线程的资源地址添加到当前线程当前线程也是可以访问到的;下图main thread访问了thread-2的局部变量;
const int num = 10;
void *threadRoutine(void *args)
{
const char *s = static_cast<const char *>(args);
int cnt = 1;
while (cnt)
{
cnt++;
cout << "thread name: " << s << " pid: " << getpid() << endl;
if (cnt == 10)
break;
}
return nullptr;
}
string str;
int main()
{
vector<pthread_t> vtid;
for (int i = 0; i < num; i++)
{
pthread_t tid;
str += "thread";
str += " : ";
str += to_string(i);
pthread_create(&tid, nullptr, threadRoutine, (void *)str.c_str());
vtid.push_back(tid);
}
for (int i = 0; i < vtid.size(); i++)
{
pthread_join(vtid[i], nullptr);
}
return 0;
}
十一、线程局部存储
11.1__thread
__thread是编译器提供的一个编译选项,让被修饰变量在线程的局部存储里都开辟一份;,本质上就不是同一个变量了;需要注意的是**__thread只能修饰内置类型**;通常是用来存储线程的属性tid、pid之类的;
__thread int val_;
//定义一个全局变量被__thread修饰,全局变量对于每一个线程都是各自私有一份;
//此技术叫做线程的局部存储;
11.2局部存储与独立栈的区别
独立栈主要是用来保证调用链的安全,里面放的是临时变量;
而局部存储类似全局数据区,存放的数据是随线程的整个生命周期的;
十二、线程分离
类似进程非阻塞等待;
默认创建的新线程是joinable需要被等待的,不进行等待无法释放资源,会造成内存泄露;主线程不关心新线程的退出情况,那是由进程来关心的,主线程关心的是函数执行完之后的返回值;如果主线程不想知道新线程的返回值,就不需要阻塞等待,此时要进行线程分离;
线程分离即新线程自己将内核管理的LWP结构和库管理的tcb结构释放;
注意,1.线程分离之后是不可以被join的;2.必须保证主线程是最后一个退出的,否则会导致整个进程的退出;
一个线程是否被分离是需要被记录下来的,所以一个线程被分离本质就是将本线程的tcb里的线程可标志位设置为1;
12.1线程分离接口
#include <pthread.h>
int pthread_detach(pthread_t thread);
//即不需要主线程阻塞等待,执行完之后自己释放;
//可以由主线程进行分离
pthread_t tid;
pthread_detach(tid);
//新线程自己释放自己
pthread_detach(pthread_self());
十三、线程的同步与互斥
多线程并发访问共享资源的时候是有数据不一致问题的;
互斥:任何时候访问资源只有允许有一个执行流;
同步:按照一定的顺序性获取资源;
原子性:在代码上表现为只有一个汇编指令,只有执行前和执行后两种状态;
对一个全局变量进行多线程并发–/++操作是不安全的,因为如–操作需要进行三步操作:1.将内存中的数据读取到CPU当中;2.在CPU内部进行–操作;3.将计算结果写回内存;
即CPU执行是按照汇编走的,应用层的一行代码会被转换成多条汇编指令,所以一行代码不具有原子性;
CPU的寄存器保存着线程的上下文,当线程被换走的时候,要从寄存器中拿走上下文,换回是要将上下文恢复到寄存器中,对于–操作最后是要将寄存器中的内容写入到内存当中的,对于共享资源多线程并发访问时,当前线程因为时间片结束导致数据没有被修改并将数据保存到上下文中,其他线程对数据成功进行了修改,当线程恢复上下文是就会用自己的上下文修改数据,但是此时的数据已经不是刚从内存读取到的数据了,这时修改内存数据就会有问题;
总结:多线程并发访问时会使得数据同时被修改,造成数据不一致问题;
解决方式:任何时候共享数据只允许有一个执行流,即互斥,这样某个线程执行的时候,其他线程就不会修改共享数据;使用锁的方式实现互斥;
互斥场景下,1.由于线程对于锁的竞争能力不一样会导致其他线程饥饿问题;2.由于大量线程是被阻塞的,一旦资源就绪,默认所有线程就会都从阻塞态变成运行态竞争锁,但是最多只有一个线程可以访问,其他线程是无效的,被伪唤醒了,而且会导致被唤醒的线程CPU都要执行一次检测逻辑,这就叫做惊群问题;
解决方式:1.所有的线程必须排队;2.释放完锁的线程不能立马申请锁,排到队尾;
这样可以让线程获取锁有一定的顺序,至少保证了每个线程都有机会获得锁,是相对公平的;
十四、互斥锁
锁是一种共享资源是通过保证申请和释放锁的原子性来保证访问锁是安全的;
先定义一把锁,然后初始化和销毁,期间对临界区加锁和解锁;
14.1锁的初始化和释放
#include <pthread.h>
int pthread_mutex_destroy(pthread_mutex_t *mutex);
//参数是锁的地址;
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
//第一个参数是锁的地址;第二个参数是锁的属性一般为nullptr;
pthread_mutex_t/*是库里面提供的一种数据类型*/ mutex = PTHREAD_MUTEX_INITIALIZER;
//使用全局或者静态的方式进行初始化之后是不需要destroy的;
14.2加锁和解锁
#include <pthread.h>
int pthread_mutex_lock(pthread_mutex_t *mutex);//加锁,申请成功可以访问,失败阻塞;
int pthread_mutex_trylock(pthread_mutex_t *mutex);//非阻塞加锁,解决死锁问题;
int pthread_mutex_unlock(pthread_mutex_t *mutex);//解锁;底层设计是为了解决死锁问题;
14.3加锁后的缺陷
加锁会导致加锁区域进行串行访问,本质上是以时间换取安全;所以应该尽量保证临界区即加锁的区域的代码要少;好处是执行时间少,串行比率降低,其他线程等待的时间减少;
加锁产生饥饿问题;
14.4加锁的细节
1.申请锁成功才可以继续往后执行;不成功就会阻塞等待资源就绪;
2.加锁之后必须解锁,否则会导致锁资源不就绪引发其他线程一直阻塞的问题,所以要保证锁一定可以被解锁,保证不会因为进行跳转不能执行到解锁;
3.不同线程对锁的竞争能力不同;不同线程对锁的访问是并发的;为了保证竞争公平要进行同步;
4.如果线程长时间得不到锁就会导致饥饿问题,即纯互斥环境,锁资源分配不够合理,导致其他进程的饥饿问题;
5.加锁后的线程也是可以被切换的,但是此线程还是持有锁,没有释放锁,其他线程是无法访问临界区的;因为其他线程只关心当前线程是持有锁还是释放锁,即是此线程对于其他线程是原子的;
14.5互斥锁的底层实现
大部分芯片在设计时一定要内置汇编指令集才能执行各种复杂的逻辑;
互斥锁底层使用了swap这样的指令,作用是将内存和寄存器中的值进行交换,一条指令所以是原子的,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期,因为多处理器也只有一套总线和内存连接,CPU访问内存是要经过硬件仲裁器决定有哪一个CPU去访问内存;即多处理器CPU访存还是串行的,只是计算是并发的;
伪代码:
每一个线程的上下文都是私有的,即寄存器中的数据都是属于线程的;线程的结构中上下文是软件上下文,在寄存器中是硬件上下文;锁本质上就是一个数字1,只有一个,swap就可以保证锁申请成功,其他指令都是后续动作;
交换的本质就是将内存中的数据(共享数据)交换到寄存器中,也就是线程的硬件上下文(私有数据)中;如果当前线程继续是申请锁就会导致唯一的1也变成0,即没人能够申请锁了,死锁了;
lock:
//8086的ax寄存器是16位的,后来eax是32位,e是expand扩展的意思,分为了ah和al各16位
movb $0,%al//将寄存器al的值设为0
xchgb al, mutex//将寄存器的值交换到变量mutex里,原本mutex是1变为了0,这就是申请锁的过程
if(al寄存器的内容 > 0)//申请成功此时寄存器内部就是1,否则就是失败挂起等待
{
return 0;
)
else
挂起等待;
goto lock;
unlock:
movb $1, mutex//将内存中的mutex变量置为1,这样的实现就可以使得其他线程来释放锁,而不是必须当前线程来释放锁;这样就可以实现两个线程的同步;如果使用的是swap就必须保证是申请锁成功的线程来释放锁;为解决死锁问题提供了方案;
唤醒等待Mutex的线程;
return 0;
14.6锁的应用——封装
#include <pthread.h>
class mutex
{
public:
mutex(pthread_mutex_t *lock) : lock_(lock) {}
void lock()
{
pthread_mutex_lock(lock_);
}
void unlock()
{
pthread_mutex_unlock(lock_);
}
~mutex() {}
private:
pthread_mutex_t *lock_;
};
class lockguard
{
public:
lockguard(pthread_mutex_t *lock) : mutex_(lock)
{
mutex_.lock();
}
~lockguard()
{
mutex_.unlock();
}
private:
mutex mutex_;
};
//可以使用代码块{}来划分对象的的生命周期
14.7死锁
同一把锁申请两次就会形成死锁,原因是底层锁其实就是1,申请了两次使得整个过程中1丢失了,所以死锁了;
14.7.1概念
死锁是指在一组执行流中的各个执行流均占有不会释放的资源,但因互相申请被其他执行流所占用不会释放的资源而处于的一种永久等待状态。
14.7.2死锁四个必要条件
互斥条件:一个资源每次只能被一个执行流使用;
请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放; 默认使用的lock接口就是请求与保持的;
不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺;
循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系;
缺一不可;
14.7.3解决死锁问题
a.破坏死锁的必要条件,如:
1.不使用互斥;2.使用pthread_mutex_trylock(pthread_mutex_t *mutex),申请失败返回然后将自己的锁释放;3.由于释放锁的底层是直接赋1,所以可以由其他线程释放当前线程的锁的;4.每一个线程申请锁的顺序要一致,否则容易形成环路申请问题;
b.加锁顺序一致;
c.避免锁未释放的场景;
d.资源一次性分配;
相关算法:1.死锁检测算法;2.银行家算法;
十五、线程安全问题
线程安全:多个线程并发同一段代码时,不会出现不同的结果。
常见的线程不安全的情况:
1.不保护共享变量的函数;
2.函数状态随着被调用,状态发生变化的函数;
3.返回指向静态变量指针的函数;
4.调用线程不安全函数的函数;
重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。
一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则是不可重入函数。