1.线程栈
我们使用的线程库是用户级线程库(pthread),我们使用 ldd mythread 可以查看mythread的链接信息。
因此对于一个线程(tast_struct)都是通过在共享空间内执行pthread_create执行线程创建的。所有的代码执行都是在进程的进程地址空间内进行执行的。在了解这些基本的概念之后,我们回顾上一篇的一个问题,pthread_t究竟是什么呢?
1.1pthread_t
上篇文章我就提到过pthread_t是一个无符号长整型整数,但是并没有说pthread_t具体是什么?但是我们把他转化为一个16进制数字时,我们发现这个数字特别想一个地址,那么这里我们需要确认的是,pthread_t 线程Id就是一个地址。而是什么地址呢?我们这里需要知道的是,线程的全部实现,并没有全部体现在操作系统内,而是操作系统提供执行流,具体的线程结构由库来进行管理。库可以创建多个线程->因此库也要管理线程。而库要管理线程也是要先描述再组织。因此在共享区里面包含了struct thread_info,里面就会保存pthread_t tid,线程私有栈等。而申请一个新的线程,库就又会在共享区内创建该线程对应的tid,私有栈等等。而返回的就是该结构的地址。因此pthread_t里面保存的就是对应用户级线程的控制结构体的起始地址!
- 主线程的独立栈结构,用的就是地址空间内的栈区
- 新线程用的栈结构,用的是库中提供的栈结构
pthread_t 到底是什么类型呢?取决于实现。对于Linux目前实现的NPTL(原生线程库)实现而言,pthread_t类型的线程ID,本质就是一个进程地址空间上的一个地址。
Linux中,用户级线程库和LWP是1:1的。
1.2用户级的线程id与内核LWP的对应关系
我们刚刚已经知道了用户级线程id和内核LWP的对应是1:1的。那么我们如果使用代码来验证一下呢?
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/syscall.h>//仅仅是了解
using namespace std;
// 带__thread 给每个线程拷一份
__thread int global_value = 100;
void *startRoutine(void *args)
{
while (true)
{
cout << "thread " << pthread_self() << " global_value: "
<< global_value << " &global_value: " << &global_value
<< " Inc: " << global_value++ << " lwp: " << ::syscall(SYS_gettid) << endl;
sleep(1);
}
}
int main()
{
pthread_t tid1;
pthread_t tid2;
pthread_t tid3;
pthread_create(&tid1, nullptr, startRoutine, (void *)"thread 1");
pthread_create(&tid2, nullptr, startRoutine, (void *)"thread 2");
pthread_create(&tid3, nullptr, startRoutine, (void *)"thread 3");
pthread_join(tid1, nullptr);
pthread_join(tid2, nullptr);
pthread_join(tid3, nullptr);
return 0;
}
我们同样使用监控脚本来看看当前系统下的LWP
while :; do ps -aL |head -1 && ps -aL|grep mythread;sleep 1;done
通过打印的结果我们发现 是能够看到用户级线程id和内核LWP的对应是1:1的。
2.分离线程
- 默认情况下,新建线程是joinable的,joinable就是可join的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成系统泄漏
- 如果不关心线程的返回值,join的一种负担;这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源。
在什么时候下会使用线程分离呢?
我们都知道主线程会join等待新线程,如果新线程一直不退出,主线程就会一直等待,等新线程退出之后释放新线程的资源,这与我们的进程阻塞式等待类似。如果当主线程并不关心或者不需要新线程的退出码时,新线程可以自己退出后自己释放自己的资源。那么主线程就可以不需要等待新线程了。这就完成了线程间的解耦。也叫做线程分离。
2.1 pthread_detch
- 函数原型:
- int pthread_detach(pthread_t thread);
- 可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离
- pthread_detach(pthread_self());
注意:joinable和分离是冲突的,一个线程不能即是joinable的又是分离的。
#include <iostream>
#include <cstdio>
#include <cstring>
#include <pthread.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/syscall.h>//仅仅是了解
using namespace std;
// 带__thread 给每个线程拷一份
__thread int global_value = 100;
void *startRoutine(void *args)
{
//线程分离
//pthread_detach(pthread_self());
while (true)
{
cout << "thread " << pthread_self() << " global_value: "
<< global_value << " &global_value: " << &global_value
<< " Inc: " << global_value++ << " lwp: " << ::syscall(SYS_gettid) << endl;
sleep(1);
}
}
int main()
{
pthread_t tid1;
pthread_t tid2;
pthread_t tid3;
pthread_create(&tid1, nullptr, startRoutine, (void *)"thread 1");
pthread_create(&tid2, nullptr, startRoutine, (void *)"thread 2");
pthread_create(&tid3, nullptr, startRoutine, (void *)"thread 3");
sleep(1);
//倾向于让主线程分离其他线程
pthread_detach(tid1);
pthread_detach(tid2);
pthread_detach(tid3);
//一旦分离不能join
int n1 = pthread_join(tid1, nullptr);
cout<<"strerror(n1): "<< strerror(n1)<<endl;
int n2 = pthread_join(tid2, nullptr);
cout<<"strerror(n2): "<< strerror(n2)<<endl;
int n3 = pthread_join(tid3, nullptr);
cout<<"strerror(n3): "<< strerror(n3)<<endl;
return 0;
}
通过这个实验也验证了一个线程不能即是detach又被join的。
3.线程互斥
3.1互斥相关概念
- 临界资源:多线程执行流共享的资源就叫做临界资源
- 临界区:每个线程内部,访问临界资源的代码,就叫做临界区
- 互斥:任何时刻,互斥保证有且仅有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
- 原子性:不会被恩和调度机制打断的操作,该操作只有两种状态,要么完成,要么未完成。
3.2 互斥量mutex
- 大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。
- 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。
- 多个线程并发的操作共享变量,会带来一些问题。
3.3 售票系统案例验证共享变量会有问题
为了验证共享变量会出问题的情况,我们模拟实现一个售票系统的案例。
#include <iostream>
#include <cstdio>
#include <cstring>
#include <pthread.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/syscall.h> //仅仅是了解
using namespace std;
int tickets = 10000; // 临界资源
void *getTickets(void *args)
{
const char *name = static_cast<const char *>(args);
while (true)
{
//临界区
if (tickets > 0)
{
cout << name << " 抢到了票,票的编号是:" << tickets << endl;
tickets--;
}
else
{
cout << name << " 已经放弃抢票了,因为没有票了....." << endl;
break;
}
}
return nullptr;
}
int main()
{
pthread_t tid1;
pthread_t tid2;
pthread_t tid3;
pthread_create(&tid1, nullptr, getTickets, (void *)"thread 1");
pthread_create(&tid2, nullptr, getTickets, (void *)"thread 2");
pthread_create(&tid3, nullptr, getTickets, (void *)"thread 3");
// sleep(1);
// // 倾向于让主线程分离其他线程
// pthread_detach(tid1);
// pthread_detach(tid2);
// pthread_detach(tid3);
// 一旦分离不能join
int n1 = pthread_join(tid1, nullptr);
cout << "strerror(n1): " << strerror(n1) << endl;
int n2 = pthread_join(tid2, nullptr);
cout << "strerror(n2): " << strerror(n2) << endl;
int n3 = pthread_join(tid3, nullptr);
cout << "strerror(n3): " << strerror(n3) << endl;
return 0;
}
执行结果我们可以发现,看似好像没有什么问题,但是其实是存在bug的。在这段代码中
这一段代码是既对票做判断,又对票做--,--是并不是由一条语句执行的,而是被翻译成3条语句执行的。
CPU对tickets--这句话,要翻译成:
- 取数据。将数据从内存取到cpu寄存器内
- load :将共享变量ticket从内存加载到寄存器中
- 做运算。在寄存器内对数据进行运算。
- update : 更新寄存器里面的值,执行-1操作
- 写回数据。将数据从寄存器写回内存。
- store :将新值,从寄存器写回共享变量ticket的内存地址
我们可以看看ticket--部分的汇编代码:
取出ticket--部分的汇编代码
objdump -d a.out > test.objdump
152 40064b: 8b 05 e3 04 20 00 mov 0x2004e3(%rip),%eax # 600b34 <ticket>
153 400651: 83 e8 01 sub $0x1,%eax
154 400654: 89 05 da 04 20 00 mov %eax,0x2004da(%rip) # 600b34 <ticket>
而这3个步骤中,线程在任何地方都有可能切换走,而CPU内的寄存器是被所有的执行共享的,但是寄存器里面的数据是属于当前执行流的上下文数据。因此线程要被切换的时候,需要保存上下文;线程要被换回的时候,需要恢复上下文。
因此为了从程序中看到可能错误的数据,我们需要加一个usleep来模拟漫长的业务过程,可能有很多个线程会进入该代码段。
#include <iostream>
#include <cstdio>
#include <cstring>
#include <pthread.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/syscall.h> //仅仅是了解
using namespace std;
// // 带__thread 给每个线程拷一份
// __thread int global_value = 100;
// void *startRoutine(void *args)
// {
// //线程分离
// //pthread_detach(pthread_self());
// while (true)
// {
// cout << "thread " << pthread_self() << " global_value: "
// << global_value << " &global_value: " << &global_value
// << " Inc: " << global_value++ << " lwp: " << ::syscall(SYS_gettid) << endl;
// sleep(1);
// }
//}
int tickets = 10000; // 临界资源
void *getTickets(void *args)
{
const char *name = static_cast<const char *>(args);
while (true)
{
//临界区
if (tickets > 0)
{
usleep(1000);//模拟漫长的业务
cout << name << " 抢到了票,票的编号是:" << tickets << endl;
tickets--;
}
else
{
cout << name << " 已经放弃抢票了,因为没有票了....." << endl;
break;
}
}
return nullptr;
}
int main()
{
pthread_t tid1;
pthread_t tid2;
pthread_t tid3;
pthread_create(&tid1, nullptr, getTickets, (void *)"thread 1");
pthread_create(&tid2, nullptr, getTickets, (void *)"thread 2");
pthread_create(&tid3, nullptr, getTickets, (void *)"thread 3");
// 一旦分离不能join
int n1 = pthread_join(tid1, nullptr);
cout << "strerror(n1): " << strerror(n1) << endl;
int n2 = pthread_join(tid2, nullptr);
cout << "strerror(n2): " << strerror(n2) << endl;
int n3 = pthread_join(tid3, nullptr);
cout << "strerror(n3): " << strerror(n3) << endl;
return 0;
}
此时我们确实看到了,产生了脏数据。
3.4 解决抢票问题
要解决以上的问题,我们需要做到三点:
- 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
- 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
- 如果线程不在临界区中执行,那么该线程不能组织其他线程进入临界区。
要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫做互斥量。
在临界区内,只能够允许一个线程执行,不允许多个线程同时执行,因此一旦我们给买票的过程加上一把锁,在某一时刻,只能够允许一个线程买票,因此可以保证整个买票的过程是原子的。
3.5互斥量的接口
3.5.1初始化互斥量
申请锁:
初始化互斥量的两种方法:
- 方法一:静态分配
- pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER、
- 方法二:动态分配
- int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
- 参数:
- mutex:要初始化的互斥量
- attr:NULL
3.5.2 销毁互斥量
释放锁:
销毁互斥量需要注意:
- 使用PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁
- 不要销毁一个已经加锁的互斥量
- 已经销毁的互斥量,要确保后面不会有线程再次尝试加锁
函数原型:int pthread_mutex_destroy(pthread_mutex_t *mutex);
3.5.3 编码
在我们上述的售票系统中,其中很明显的是,票数tickets属于临界资源,我们需要对其进行加锁。
在我们申请锁成功之后,我们对互斥量进行加锁和解锁,我们将使用pthread_mutex_lock和pthread_mutex_unlock。
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
返回值:成功返回0,失败返回错误号
注意:我们加锁只需要对临界区加锁,而且加锁的粒度越细越好。而加锁的本质是让线程执行临界区的代码串行化。
调用pthread_mutex_lock时,可能会遇到一下情况:
- 互斥量处于未锁状态,该函数将会互斥量锁定,同时返回成功
- 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_mutex_lock调用会陷入阻塞(执行流被挂起),等待互斥锁解锁。
#include <iostream>
#include <cstdio>
#include <cstring>
#include <pthread.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/syscall.h> //仅仅是了解
using namespace std;
int tickets = 10000; // 临界资源
pthread_mutex_t mutex;//定义锁
void *getTickets(void *args)
{
const char *name = static_cast<const char *>(args);
while (true)
{
//临界区
pthread_mutex_lock(&mutex);
if (tickets > 0)
{
usleep(1000);
cout << name << " 抢到了票,票的编号是:" << tickets << endl;
tickets--;
pthread_mutex_unlock(&mutex);
}
else
{
cout << name << " 已经放弃抢票了,因为没有票了....." << endl;
pthread_mutex_unlock(&mutex);
break;
}
}
return nullptr;
}
int main()
{
pthread_mutex_init(&mutex,nullptr);
pthread_t tid1;
pthread_t tid2;
pthread_t tid3;
pthread_create(&tid1, nullptr, getTickets, (void *)"thread 1");
pthread_create(&tid2, nullptr, getTickets, (void *)"thread 2");
pthread_create(&tid3, nullptr, getTickets, (void *)"thread 3");
// 一旦分离不能join
int n1 = pthread_join(tid1, nullptr);
cout << "strerror(n1): " << strerror(n1) << endl;
int n2 = pthread_join(tid2, nullptr);
cout << "strerror(n2): " << strerror(n2) << endl;
int n3 = pthread_join(tid3, nullptr);
cout << "strerror(n3): " << strerror(n3) << endl;
pthread_mutex_destroy(&mutex);
return 0;
}
3.5.4 互斥锁的相关问题
- 加锁是一套规范,通过临界区对临界资源进行访问的时候,要加就都需要加锁,不能有的线程加锁有的线程不加锁。
- 锁保护的是临界区,任何线程执行临界区代码访问临界资源,都必须现申请锁,前提是都必须看到锁!那么这把锁本身也是临界资源!而锁的设计者也考虑了这个问题,pthread_mutex_lock线程竞争锁的过程,就是原子的!