程序的一个执行流就是线程,准确的说:线程是在进程内部运行的一个执行流,属于进程的一部分。每个进程都至少有一个执行流,称为主线程,在进程中可以有多个执行流,也就是有多个线程。
进程:程序代码 + 相关数据集 。程序被触发后,将程序的代码与所需数据加载到内存中,这个过程就是进程。进程是承担分配系统资源的基本实体
,也就是说创建一个进程,时间空间的消耗是较大的,需要有物理内存,再通过页表,将虚拟内存和物理内存进行映射,还需要有进程控制块,来控制进程。那么我有个疑问?如果我就想简单的运行一个执行流,每次都得大费周章的创建进程,效率是不是有些慢。那么其实就有了线程的出现。
先简单画一下进程:
这是我们常学到的进程,由一个进程控制块来管理进程。上面概念里说过,线程是在进程中运行一个执行流,这该怎么理解?线程需要被管理起来吗?当然需要,由谁管理?也是由进程控制块管理的。一个进程控制块能管理多个线程?不可以。一个进程块可以管理一个进程?那么进程中多线程是如何实现的?
上面的整体是进程的一种,而且是比较特殊的进程,它只有一个执行流(只包含主线程)。有多个线程的进程是什么样的?如图:
可以由多个进程控制块来指向同一个进程地址空间。上图也是一个进程,不过它有多个线程,所以有多个进程控制块来管理线程。一个进程控制块就对应一个执行流。对,就是这样,Linux系统就是这样来实现线程的,感觉怪怪的,不过是真的好用,对比一下window系统的线程实现,就明白这样实现的好处了。
管理线程,先描述在组织。那么管理线程必须要有线程控制块。没听错,是单独为了管理线程而创的结构体,这样做的成本非常高。
因为这样实现线程,会导致操作系统,还得单一识别一下是进程控制块还是线程控制块,管理进程和管理线程用的是两套方案,这里的线程可以看一个轻量化的进程,无疑这样会给cpu带来负担。
Linux实现线程,还是用的进程控制块管理的线程,进程去向操作系统要空间,然后线程作为进程中的一个一个的执行流,如果不额外创建线程,默认情况下进程只有一个主线程,也就一个执行流。
为什么Linux的线程实现使得cpu负担减轻了呢?cpu只用识别进程控制块,不用做区分。所以线程是cpu调度的基本单位,承担进程资源的一部分
。
可以想一下:多个线程共用一个进程的虚拟地址空间,其中的一个线程出现异常,会不会影响到其它的线程。
答案是:会影响,一个线程如果出现异常退出,它会导致进程异常退出,进程都退出了,其余的线程必然也都异常退出。
这个我们待会会验证。
进程具有独立性是好理解的,不同的进程有不同的虚拟地址空间(父子进程有点特殊),不同的页表映射,所以每个进程间是独立的,如果想要进程间通信,需要开辟一个共享内存,来完成通信。
线程的大部分资源都是共享的,同一个进程内多个线程,共用一个虚拟地址空间,也就是说这些线程都能够调用虚拟地址空间的代码区里的函数,还能使用同一个全局变量,这是好理解的,做一下总结:
线程也是有私有资源的,人家也是有隐私的。比如:产生的临时数据,线程自身的属性等。虚拟地址空间是有栈区的它是用于存储临时变量的,所有线程都用一个栈?答案:当然不可以这样做。这个栈只给主线程使用。那么其余线程的临时数据存在哪里?存在共享库
中的私有栈中,后面会画图讲解这里。私有资源,做一个总结:
总结有五种关系:
用户想要创建线程的成本是很高的,因为Linux系统并没有直接提供相关的接口,所以有大佬们为了降低使用成本,做了一个第三方库,给用户去调用,从而能够创建线程,使用线程。
这是一个动态库,是运行时进行链接的。有了这个理解后,我们回到上面,解决一个问题:线程的临时变量在共享库中如何存储?
画图:
共享库映射区在虚拟地址空间中,那么线程的临时数据存的物理地址在哪?在共享库中。
首先,线程库在磁盘上,使用它所以要加载到内存中。
其次,线程要存临时数据,就要通过页表和线程库构成映射
就是这样的,而且共享库映射区,是有一个虚拟地址空间地址 ,通过这个虚拟地址空间,以及在页表中的映射关系,就可以找到线程库中存的临时数据。
最后,简易理解一下,在线程库中线程私有资源的存储:
举个例子,线程库中有一个结构体数组 tcb[1000],这就表示可以存1000个线程私有资源块。
描述用户级的线程控制块(私有资源块):
简单来说,线程的私有资源由用户级线程控制块保存,保存在线程库中的线程控制块数组中。
得用第三方库 POSIX线程库。所以使用时必须满足以下条件:
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,void *(*start_routine) (void *), void *arg)
函数的参数:
函数的返回值:
成功返回 0 ,失败返回 错误码。
赶快验证一波:我们来创建线程
#include
#include
#include
#include
void *thread_run(void* args)
{
while(1)
{
printf("I am new thread\n");
sleep(2);
}
}
int main()
{
pthread_t tid;
pthread_create(&tid, NULL, thread_run, "new thread");
while(1){
printf("I am main thread\n");
sleep(1);
}
}
看一下运行结果:
此函数用于返回线程的ID。
可以用上面的程序,简单验证一下,还用上面的程序,稍改就行:
#include
#include
#include
#include
void *thread_run(void* args)
{
while(1)
{
printf("I am new thread,my id is:%d\n",pthread_self());
sleep(2);
}
}
int main()
{
pthread_t tid;
pthread_create(&tid, NULL, thread_run, "new thread");
while(1){
printf("I am main thread,my id is:%d\n",pthread_self());
sleep(1);
}
}
发现线程 ID如上,这是用户级的线程ID,它本质就是虚拟地址空间中 的一个地址,操作系统操作线程,用的是内核级线程 ID -> LWP。
可以用指令 ps -aL
查看:
可以看到,PID是用于标识进程的,主线程的LWP和进程的PID是相同的,这也就解释了为什么,默认进程是有单一主线程的。可以看到,这两个线程的进程是同一个:PID都相同,所以是同一个进程下的两个线程。
一般来说,线程也是需要等待的,如果不等待,可能会有类似“僵尸进程”的情况。
比如:已经退出的线程,没有被回收资源,那么它的资源不会被释放。
int pthread_join(pthread_t thread , void ** retval)
;
这里比较难理解的就是第二参数是一个二级指针,因为线程的函数退出类型是 void*,想要拿到线程的退出信息,得是一个 void**,这样讲大家应该 get到了。
拿到的退出信息有:
可以做一个简单的验证:
#include
#include
#include
void* pthread_run(void* arv)
{
printf("i am new prhread\n");
sleep(2);
return (void*)111;
}
int main()
{
pthread_t id;
pthread_create(&id,NULL,pthread_run,"new");
sleep(1);
printf("wait begin\n");
void* status =NULL;
int ret = pthread_join(id,&status);
printf("ret :%d,退出信息:%d\n",ret,(int)status);
}
简单说一下:我创建了一个线程,它的返回信息 我给成 111 ,返回类型是 void*,所以需要做强转。然后就是等待此线程,拿出退出信息。
我们来运行一下:
线程有几种终止方式呢?
#include
#include
#include
void* pthread_run(void* arv)
{
printf("i am new prhread\n");
sleep(2);
//return (void*)111;
pthread_exit((void*)222);
}
int main()
{
pthread_t id;
pthread_create(&id,NULL,pthread_run,"new");
sleep(1);
printf("wait begin\n");
void* status =NULL;
int ret = pthread_join(id,&status);
printf("ret :%d,退出信息:%d\n",ret,(int)status);
}
来看看运行结果:
这里还是可以验证一下的:
#include
#include
#include
void* pthread_run(void* arv)
{
printf("i am new prhread\n");
sleep(20);
//return (void*)111;
//pthread_exit((void*)222);
return 0;
}
int main()
{
pthread_t id;
pthread_create(&id,NULL,pthread_run,"new");
sleep(1);
printf("cancle begin\n");
pthread_cancel(id);
void* status =NULL;
int ret = pthread_join(id,&status);
if(status == PTHREAD_CANCELED)
{
printf("设置好了退出信息\n");
}
}
这个程序,也能验证上面 进程等待退出信息:被其他线程cancle掉后,会设置退出信息为PTHREAD_CANCELED
运行结果:
有没有一种情况:我不想等待线程,这个线程,完成它的功能自己退出,并释放资源就好了,我不关心它的退出信息。那么就是线程分离。
可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离。
这是好理解的,相当于 线程不需要被等待,自己运行结束就释放资源。
如果是多线程去执行同一个函数,那么此函数就被重入了,函数中的比如全局变量,它的原子性就无法保持,所以需要加锁来对此函数的临界区进行保护。这有点不好理解,我可以用一个代码来验证上面的问题,然后利用锁来解决问题。
#include
#include
#include
#include
using namespace std;
class ticket
{
private:
int _tickets;
public:
ticket(int n=1000):_tickets(n)
{
;
}
~ticket()
{
;
}
int get_ticket()
{
int res = 1;
if(_tickets > 0)
{
usleep(1000);
cout<<"I am" <<pthread_self()<< "抢走了票:"<< _tickets <<endl;
_tickets--;
}
else
{
res = 0;
cout<<"票已经被强空了"<<endl;
}
return res;
}
};
void* Buy_tickets(void * ars)
{
ticket* T = (ticket*) ars;
while(true)
{
if(!T->get_ticket())
{
break;
}
}
}
int main()
{
ticket* T =new ticket();
pthread_t id[5];
for(int i=0;i<5;i++)
{
pthread_create(id+i,NULL,Buy_tickets,(void*)T);
}
for(int i=0;i<5;i++)
{
pthread_join(id[i],NULL);
}
return 0;
}
可以看到,上面就是我写的抢票程序,共有 5 个线程,去完成抢票,当票为空的时候,不会再去抢票。
运行一下:
惊奇的发现:票没了,但是还在抢票,而且抢的票还是一个负数。是什么原因呢?
因为五个线程重入了一个函数,而且访问的是同一个类对象 ticket T。都访问了类中的get_ticket(),这个函数中的临界区,没有加锁,所以导致 线程不安全,原子性丢失。
这样分析肯定是有点难理解,不过耐心点,下面会讲清楚的。
首先 我们来学习 互斥量 锁 mutex。
pthread_mutex_t 是锁的类型,本质也是一个变量。
有三种 创建锁的方式:
(1) 需要对锁 进行初始化(原生线程库,系统级别)
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
函数的参数 :mutex 就是我们创建的 pthread_mutex_t 类型的变量的地址,restrict mutex 就是 创建出锁的属性设置,默认给null就可以了。
函数的返回值:成功返回 0,失败返回错误码
int pthread_mutex_destroy(pthread_mutex_t *mutex);
函数的参数:mutex 就是我们创建的 pthread_mutex_t 类型的变量的地址
函数的返回值:成功返回 0,失败返回错误码
int pthread_mutex_lock(pthread_mutex_t *mutex)
函数的参数:mutex 就是我们创建的 pthread_mutex_t 类型的变量的地址,前提是这个变量已经被初始化了。
函数的返回值:成功返回 0,失败返回错误码
int pthread_mutex_unlock(pthread_mutex_t *mutex)
函数的参数:mutex 就是我们创建的 pthread_mutex_t 类型的变量的地址,前提是这个变量已经被初始化了。
函数的返回值:成功返回 0,失败返回错误码
(2) 不需要对锁进行初始化 (C++语言级别)
C++11,就对这个互斥量 mutex 封装成了一个类,更加的方便去调用。
我们不需要对它初始化,只要有了类对象,它会自动调用它的构造函数,也不用手动的释放锁,它会自动调用析构函数。只需要使用它的两个接口 lock()和 unlock() 就可以完成加锁和解锁。
(3) 静态的声明一个锁
static pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;
类似这样,声明一个静态的锁,也是可以的。这样就省去了初始化锁,和销毁锁。想要使用该锁还是需要调用接口:int pthread_mutex_lock(pthread_mutex_t *mutex)
,int pthread_mutex_unlock(pthread_mutex_t *mutex)
。
好,现在我们就对抢票程序,完成加锁:
#include
#include
#include
#include
using namespace std;
class ticket
{
private:
int _tickets;
pthread_mutex_t mtx;
public:
ticket(int n=1000):_tickets(n)
{
pthread_mutex_init(&mtx,NULL);
}
~ticket()
{
pthread_mutex_destroy(&mtx);
}
int get_ticket()
{
//static pthread_mutex_t mytx = PTHREAD_MUTEX_INITIALIZER;
int res = 1;
//pthread_mutex_lock(&mytx);
pthread_mutex_lock(&mtx);
if(_tickets > 0)
{
usleep(1000);
cout<<"I am" <<pthread_self()<< "抢走了票:"<< _tickets <<endl;
_tickets--;
}
else
{
res = 0;
cout<<"票已经被强空了"<<endl;
}
//pthread_mutex_unlock(&mytx);
pthread_mutex_unlock(&mtx);
return res;
}
};
void* Buy_tickets(void * ars)
{
ticket* T = (ticket*) ars;
while(true)
{
if(!T->get_ticket())
{
break;
}
}
}
int main()
{
ticket* T =new ticket();
pthread_t id[5];
for(int i=0;i<5;i++)
{
pthread_create(id+i,NULL,Buy_tickets,(void*)T);
}
for(int i=0;i<5;i++)
{
pthread_join(id[i],NULL);
}
return 0;
}
运行结果:
很明显,现在已经运行成功,但是还有一个问题,那就是:总是一个线程在抢票,其他线程竞争不过它,怎么让其它的线程也能抢上票呢?那就是同步。
原子性,以前我们都是感性的理解,现在我来讲讲它的原理。
原子性:只要代码的本质是一条汇编语言,那么它就是原子性的。
提个问题:为什么上面的抢票程序,不加锁,就会出现问题?临界区的哪行代码破坏了原子性,那就是
_tackets- -;这不就是一句代码吗?错了,在汇编层其实是 三行代码。
验证:
int main()
{
int a = 0;
a--;
return 0;
}
看到了 汇编是三行,先是将 [a]的值保存到 寄存器 eax,然后 对寄存器中的eax进行 - 1,最后再将 寄存器中的值保存到 [a]中。
它是先保存到寄存器中,然后进行的 -1 操作,那么我来解释一下,为什么上面不加锁会出现问题:
假如:
现在有俩个线程 A和B ,它俩去抢票, 总共有 1000张 ,比如先是 A 去抢票:
现在 B线程来了,它的竞争力非常强,A线程还没返回,B线程说:你把寄存器中内容先保存到你的上下文中,现在我要开始强票了,A线程说:好的,大哥,我保存一下寄存器的临时数据,我溜了,一会再来。
结果呢:B线程一直抢票,把票抢到只剩下 10 张,
此时:B线程,抢不动了,所以A线程开始运行,但是有个大问题,A线程保存的寄存器临时数据是999,它再次运行会把寄存器的数据存到内存中,这就导致票数 回到了 999 ,也就说 B线程白干了。
这就破坏了原子性,所以一个线程做一件事要做完嘛,做的半路被打断,容易出问题。
怎么才能保护临界区临界资源的原子性呢?可以使用互斥量 mutex ,它本质就是一个变量,默认情况下,它的值是 1。 如果有线程申请锁,那么 mutex的值置为 0;下一个线程来申请锁,发现 mutex的值为 0,那么线程挂起等待。就这样 保护一个线程运行临界区时的原子性。
那么我有点问题:锁也是临界资源,它的原子性谁保证?锁的申请 是用的一条汇编代码 xchgb 寄存器,mutex;
锁的释放 是用的一条汇编代码 movb 1,mutex。这用一条汇编代码,就是保证了锁的申请和释放是原子性的。
如下图:
假如有A,B线程来申请锁,A线程的寄存器 al被置为 0,mutex默认值为 1,所以 xchgb 交互一下 al 和 mutex的值,al寄存器现在的值 为 1,mutex的值为 0,表示 A线程申请锁成功,现在锁已经没有了;B线程来了,将寄存器al的值,置为0,注意 B线程来了,A线程中al中值会被覆盖,但是 A线程会自行保存al中的值,下次 A线程来了会恢复上下文数据的。但是mutex的值是内置变量,它现在还是 0表示锁被申请走了,所以 B线程被挂起等待。
如果 A线程释放锁,那么 mutex的值置为 1,再唤醒 被挂起的 B 线程就好了,然后 B线程就可以申请锁了。
我现在懂了,是这样完成 加锁,解锁的。但是 有没有可能 线程A申请到锁了 ,开始执行临界区代码,突然被切到别的线程了? 是有可能的,但是 线程A是带着锁
被切走的,它没释放锁,现在执行的线程可以申请到锁吗?
当然不能,线程A 并没有释放锁,所以 线程A对临界区的原子性保护依旧存在,其他的线程 都无法申请到锁,只能乖乖的切会到 线程A,等人家释放了锁,再去 重新申请锁。当然这里不能混淆,锁是可以有多个的,但是一个临界区由一个锁维护就够了。
上面的加锁线程 A,它就是 一直抱着锁 不释放,所以一直都是它来运行,这就造成了其他线程饥饿问题,如何才能够解决呢?同步
同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步
所以 有了以上理解,现在来谈谈一种特殊的锁 : 死锁
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资
源而处于的一种永久等待状态。
怎么理解呢?我举个例子:
主线程 去调用一个函数 insert(),这个函数是临界资源;自定义函数 hander()是对2号信号的捕捉,那么这就可能产生死锁:
main()函数调用 insert(),申请到锁,但是 中途收 2号信号,去执行 handler函数,又去调用 insert(),那么又去申请锁。操作系统懵了,我给你这个线程一把锁,但是 同样是你 咋又来申请锁了?那么这个线程 就一直被挂起了。
也就是说 :一个线程 重复申请同一个锁,就会导致 死锁。
什么时候需要有同步呢?
在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题。
同步的实现用的是条件变量,互斥实现用的是互斥量,所以条件变量也没那么神奇,不过就是让线程满足 条件变量时,做相应的操作罢了。
初始化条件变量:int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrictattr);
函数参数 :第一个参数是要初始化的条件变量的地址;第二个参数是创建条件变量的属性,默认给null就行了
函数返回类型:成功返回0,失败返回错误码
销毁条件变量:int pthread_cond_destroy(pthread_cond_t *cond);
函数参数:要销毁的条件变量的地址
函数返回类型:成功返回0,失败返回错误码
使得线程在某个条件变量下等待:
int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);
函数参数:第一个参数表示在那个条件变量下等待;第二个参数是互斥量,等待前所待的锁
函数返回值:成功返回 0,失败返回 错误码
线程的等待,需要被唤醒,它可不能死等:
int pthread_cond_signal(pthread_cond_t *cond);
函数的参数:条件变量的地址
函数的返回值:成功返回 0,失败返回错误码
唤醒所以在这个条件变量下等待的线程:
int pthread_cond_broadcast(pthread_cond_t *cond);
函数的参数:条件变量的地址
函数的返回值:成功返回 0,失败返回错误码
举个例子:一个老板boss,手底下有 5个员工Employee。我要求 boss线程,控制 这个 5个Employee线程:
一步一步来完成,方便理解互斥和同步:
#include
#include
#include
#include
using namespace std;
void* work(void* j)
{
int n = *(int*)j;
while(true)
{
cout<<"i am "<<n<<"employee"<<"working"<<endl;
sleep(1);
}
}
int main()
{
pthread_t id[5];
for(int i=0;i<5;i++)
{
void* j =(void*)&i;
pthread_create(id+i,NULL,work,j);
}
for(int i=0;i<5;i++)
{
pthread_join(id[i],NULL);
}
return 0;
}
这就简单的创建了 5个线程,都去执行work 函数,但是 work函数是里的代码基本都是临界资源,所以加上锁才是比较好的决策:
#include
#include
#include
#include
using namespace std;
pthread_mutex_t mtx;
void* work(void* j)
{
pthread_mutex_lock(&mtx);
int n = *(int*)j;
while(true)
{
cout<<"i am "<<n<<"employee"<<"working"<<endl;
sleep(1);
}
pthread_mutex_lock(&mtx);
}
int main()
{
pthread_t id[5];
pthread_mutex_init(&mtx,NULL);
for(int i=0;i<5;i++)
{
void* j =(void*)&i;
pthread_create(id+i,NULL,work,j);
}
for(int i=0;i<5;i++)
{
pthread_join(id[i],NULL);
}
pthread_mutex_destroy(&mtx);
return 0;
}
我们看一下运行结果:
发现一直是 第5号员工,在工作,非常不银杏,所以决定使用同步,来分配一下,解决一下 其他线程的饥饿问题,需要有一个老板来进行管理:
#include
#include
#include
#include
using namespace std;
pthread_mutex_t mtx;
pthread_cond_t con;
void* ctrl(void* j)
{
string name =(char*)j;
while(true)
{
sleep(1);
cout<<"i am"<<name<<"please work"<<endl;
pthread_cond_signal(&con);
// pthread_cond_broadcast(&con);
}
}
void* work(void* j)
{
pthread_mutex_lock(&mtx);
int n = *(int*)j;
delete (int*)j;
while(true)
{
pthread_cond_wait(&con,&mtx);
cout<<"i am "<<n<<"employee"<<"working"<<endl;
}
pthread_mutex_unlock(&mtx);
}
int main()
{
pthread_t id[5];
pthread_mutex_init(&mtx,NULL);
pthread_cond_init(&con,NULL);
pthread_t boos;
pthread_create(&boos,NULL,ctrl,(void*)"boss");
for(int i=0;i<5;i++)
{
int * num= new int(i);
pthread_create(id+i,NULL,work,(void*)num);
}
for(int i=0;i<5;i++)
{
pthread_join(id[i],NULL);
}
pthread_cond_destroy(&con);
pthread_mutex_destroy(&mtx);
return 0;
}
我们看效果:
它的第二个参数 是 锁的地址。感觉有点奇怪:
建议:保证 pthread_cond_wait的原子性,因为它要等待被唤醒,这个过程不应该被打断,因为被解锁后,可能会错过被唤醒,得上锁,意思就是在lock和unlock之间进行等待,但是啊,它等待的过程中,是会自动解锁的,这真的有点绕。
如果wait时它依旧一直上锁,别的线程想要访问临界资源,无法和它竞争锁,会一直等待,这不太好,所以
pthread_cond_wait偷偷的做了两件事:
关于这个,在我后续的博客< 生产者,消费者>里,还会说的。如果这里get不到,那就满满去体会,这话说的有的像个渣男。
以上就是线程的内容,包括线程的概念理解,线程的控制,以及线程互斥和线程同步的实现。有问题的朋友,可以私信或评论,感觉有帮助的朋友可以给个小赞,支持一下。