目录
一、POSIX线程库
二、线程创建
1.创建线程的接口
2. 错误的创建多线程
3.正确的创建多线程
4. 线程的私有栈结构
三、线程终止
1. 函数结束
2. 调用pthread_exit()终止
3.调用pthread_cancel()函数
四、线程等待
1. 线程等待函数
2. 线程阻塞式等待
3.分离线程
4. 线程的退出信息
五、C++11中的线程
六、线程id问题
七、主线程与其他线程单独拥有共享资源
八、自行封装一个简单的线程类
一、POSIX线程库
大家知道,在linux中因为linux 线程方案的影响,它是没有真正意义上的线程的,所以linux只提供了创建轻量级进程的系统接口,而没有提供创建线程的系统接口。但是CPU和用户(程序员)又只认线程,所以linux中就必须要有能够创建线程的方法。因此,就有人在linux中提出了“线程库”的概念,线程库是一个动态库,对linux中的关于轻量级进程的接口及其他内容进行了封装,通过使用线程库的方式让其他人能够在linux中模拟创建出线程以供使用,究其本质,依然是“轻量级进程”。
在linux中的线程库是POSIX标准下的线程库,它与线程有关的函数构成了一个完整的系列,绝大多数函数名都是有“pthread_”开头的。
如果要使用这些函数库,就要引进<pthread.h>头文件
当使用了线程库内的函数后,在链接时要加上“-l pthread”命令来链接线程库。
二、线程创建
1.创建线程的接口
要创建一个线程,直接使用“pthread_create()”接口即可。
其中的第一个参数thread返回线程id,当然,实际打印出来后其实是一个地址。
第二个参数attr是设置线程的属性,一般不用管,直接设置为nullptr即可。
第三个参数是一个函数地址,表示线程启动后要执行的函数。
第四个参数arg是传给线程启动函数的参数。这些内容在上一篇文章“线程的基本概念”都有所涉及,就不再过多讲解。
该函数成功时返回0,失败时会返回错误码。
2. 错误的创建多线程
利用pthread_create()接口,创建5个线程:
运行该程序:
可以正常运行,但是仔细观察后就可以发现,这里显示的线程名字,全部都是5。这就很奇怪了,在上面我们明明是创建了5个线程,那这里为什么只显示一个线程呢?修改程序如下:
仅仅向创建下次的代码块中加入一个sleep(1),让每个线程创建完后休眠1s。再运行该程序:
此时就可以看见5个线程的创建了。
那么,为什么会出现这种情况呢?首先要知道,在上述程序中,传给线程启动函数的参数,其实是“缓冲区的起始地址”,并不是直接将缓冲区内的内容传过去。
其次,如果一个父进程中创建了一个子进程,那么子进程和父进程谁先运行呢?答案是我们并不确定。因为这两个进程的运行顺序完全由调度器决定,我们并不知道调度器会先调度哪个进程。而我们知道,CPU调度的基本单位是线程,因此,当一个线程被创建出来后,新线程其该进程内的其他线程相比,谁先运行我们也并不知道。
因此,就可能会出现,当一个线程被创建出来后,刚把缓冲区的地址传给启动函数,就被CPU切走运行主线程,此时循环到达尽头从头开始,缓冲区被销毁,重新创建缓冲区。主线程此时就会向缓冲区内写新的内容并创建新的线程,而该线程被创建出来后也仅仅是向启动函数写入缓冲区的起始地址。重复上述过程直到主线程出循环。此时就会导致所有线程或者绝大部分线程的启动函数中被传入的缓冲区起始地址对应的都是编号5,进而出现上图中一直打印编号为5的线程。其实它们并不是同一个线程,仅仅是不同的线程拿到了同一个编号。
有人可能会疑惑,既然缓冲区被销毁并重新创建缓冲区,那为什么传给它们的缓冲区地址都是同一个呢?原因很简单,因为在销毁缓冲区后回到循环最上面向下运行的过程中,并没有创建原来不在这个循环中的新的内容,因此,极大概率循环下去时该循环中的变量会在原地址重新生成对应变量,包括缓冲区。
当在程序中加上了一个sleep(1)后,主线程创建完新线程后就会休眠被挂起,在这个过程中,新线程的启动函数就已经通过缓冲区起始地址拿到了对应数据,因此不再受缓冲区内容更改的影响。
因此,在创建多线程时,其实上面的方法是有问题的,在实际中最好不要用这种方式创建多线程。
3.正确的创建多线程
在上面单纯的使用循环创建新线程时,之所以会出现传入的数据有错误的情况根本原因,还是因为这些线程共享了同一个namebuffer。所以,为了避免出现这种情况,就可以通过对要传给新线程的数据进行封装和单独new空间,让不同的线程拥有属于自己的那么buffer:
通过这种方式,传递给线程启动参数的地址就是new出来的新空间的地址,而new出来的空间存放在堆上,不受作用域的影响,只在显式调用delete或程序结束时才会销毁。因此不受循环结束销毁数据并在重新循环在原地创建变量的影响。运行该程序:
可以看到,此时每个线程的编号就是正确的。当然,如果你想让主线程拿到新线程的数据,也是可以的。定义一个vector将新线程的指针传入即可:
运行程序后,就可以通过主线程拿到其他线程的数据了。这也就证实了进程中,线程的数据其实是共享的理论。
4. 线程的私有栈结构
现在有如下测试程序:
这个程序中创建了5个线程,且每个线程执行的都是同一个函数。 那么,当这个程序运行起来后,这些线程进入了同一个函数“start_routine()”处于什么状态呢?答案是“重入状态”,即该函数在被多个执行流反复进入。
那这里就有一个问题,那么该函数到底还是可重入函数还是不可重入函数呢?如果抛开它正在进行的打印代码,它就是一个可重入函数。那此时就又有一个问题,这些线程进入的是同一个函数,那么它们按道理来讲应该就是用的同一批临时变量啊,既然是同一批临时变量,就意味着不同的线程进入执行就可能会影响到其他线程,这很明显就不是可重入函数啊。那么这些线程在进入同一个函数时,使用的究竟是不是同一批临时变量呢?修改程序如下进行测试:
运行程序:
可以看到,每个线程的cnt的值的地址其实都是不一样的,使用的都是不同的cnt,也就不会互相影响。那为什么每个线程的cnt都不一样呢?原因很简单,cnt在一个函数内,是一个局部变量,具有临时性。而这些临时数据,都被保存在线程自己的独立栈结构中。
三、线程终止
1. 函数结束
线程终止是比较简单的。线程说白了就是一个去执行进程内的某个函数块的执行流。所以,线程终止的第一种方式,就是函数调用结束。
这个道理很简单,就不再演示了。
2. 调用pthread_exit()终止
如果要在函数未结束的情况下终止线程,就可以调用pthread_exit()函数。注意,在线程内,不能调用exit()终止。因为exit()是让OS系统下整个进程发送某个信号来终止进程,如果用exit()来终止线程,就会导致该进程结束,以至于所有线程都结束。
例如上图的程序中,就创建了5个线程,但是在线程的启动函数中用exit(0)来结束该线程:运行程序:
此时,当新线程运行启动函数后,就遇见了“exit(0)”,导致整个进程退出。
而pthread_exit()函数则仅仅是结束当前线程,不会影响整个进程。它的参数可以暂时不管,设置为nullptr即可。将代码修改如下:
运行程序:
可以看到,此时这几个线程都是默认运行了一次的,未受其他线程退出的影响。当然,这里的打印有点问题是因为该函数中进行了I/O,导致该函数是一个不可重入函数,在打印时对应线程可能会被切走导致出现问题。
3.调用pthread_cancel()函数
pthread_cancel()函数也是可以终止一个线程的。但是与调用pthread_exit()终止或函数结束返回,可以设置返回内容不同,通过pthread_cancel()函数终止的线程,会默认返回退出码-1。
可以看到,线程2的返回码就是-1。如果不理解这里的返回码的由来,可以去看看线程等待部分中的线程退出信息部分。
注意,pthread_cancel()函数只能取消正在运行的线程,如果这个线程结束的太快导致运行该函数的执行流还没有运行到该函数,即运行到pthread_cancel()函数前,该函数要取消的线程就已经结束,该函数就会失效。
四、线程等待
大家仔细观察线程的启动函数就可以发现,它其实是有返回值的,返回值为void*。
为什么这个启动函数会有返回值呢?其实是因为线程也是需要被等待的。当一个线程结束后,它的PCB并没有被回收,这就会导致该线程的资源和退出信息没有被释放和回收,进而就会导致出现类似僵尸进程的问题,即内存泄漏。
因此,线程在结束后,也是需要被主线程等待,让其他线程获取线程的退出信息和回收对应的资源,防止内存泄漏。当然,线程的退出信息我们可以不关心,但是必须要关注线程所使用的资源。
1. 线程等待函数
要等待线程,就要调用pthread_join()函数。
该函数的第一个参数thread就是创建线程时得到得到的线程id,第二个参数这里暂时先不谈。
因此,在线程创建完后,应该加上对应的线程等待函数,这才是一个线程完整的创建和结束过程。
运行该程序:可以看到,此时就等待成功了。
2. 线程阻塞式等待
注意,在线程等待时采用的是阻塞时等待,这就意味着如果被等待的线程一直未结束,那么等待线程就会一直阻塞在pthread_join()函数处,无法执行其他操作。
写出上图程序,在该程序中创建了一个新线程,该线程在循环打印一个字符串。而主线程中则需要等待该线程的结束。运行该程序:
可以看到,该程序一直在反复打印一个字符串,这个字符串就是线程要执行的内容。而等待该线程的主线程却没有进行打印。原因就是此时被等待的线程未结束,主线程处于阻塞状态,无法执行其他操作。
3.分离线程
上文中说了,当一个线程未结束,而另一个线程在等待该线程时,等待线程会处于阻塞状态,无法执行其他操作。那么,如果我们不想等待这个线程呢?此时就可以使用“分离线程”。
一般来讲,如果我们不关心线程的返回值,那么join等待线程结束回收资源其实就是一种负担。在这种时候,就可以告诉OS,该线程已经退出,让OS来释放线程资源即可。这种不再由线程等待回收资源,而是直接让OS来回收资源的方法,就叫做“分离线程”。
线程分离,可以让主线程去分离线程,也可以让线程自己分离自己。那么如何让一个线程获取自己的线程id呢?很简单,调用thread_self()函数即可:
该函数的返回值就是该线程的id。
要实现分离线程,就要使用pthread_detach()函数:
该函数可以将指定线程分离。一个线程的状态默认为joinable,即可被等待的。但如果被设置为了分离状态,就不再能被等待了,而是在线程结束后让OS系统释放它的资源。
写入如下测试程序,在该程序中,新线程自己分离自己,主线程中有一个等待函数从,测试新线程是否分离成功:
运行该程序:
可以看到,在该程序运行完后,最下面打印出了一句话。这个话其实是在程序等待成功后才会打印的。也就是说,虽然这个程序中的新线程自己分离自己,但却没有分离成功,依然是主线程等待了新线程。
原因很简单,因为新线程和主线程在CPU中谁先被调度我们并不知道,此时就可能出现一种情况:新线程被创建后,它到自己的启动函数中执行代码,但是还未执行到分离函数就被CPU切走换成了主线程执行代码。此时主线程就直接运行到了等待函数的位置开始了阻塞时等待。当主线程处于等待状态时,CPU又将新线程切换回去执行代码,虽然新线程在这一次执行中执行到了分离函数,但由于主线程还处于阻塞状态, 并不知道新线程的这一变化。因此当新线程执行完毕后,依然是由主线程回收新线程的资源。这个问题总结起来就一句话——新线程执行分离函数的速度太慢了。
要解决这一问题也很简单,那就是在等待函数之前加上分离函数即可:
运行程序:
可以看到,此时出现了一个报错。这个报错的原因很简单,因为当一个线程被分离后,它就不能再被等待,因此,该程序在运行到等待函数的时候就会报错。这也说明了此时分离线程成功。将线程等待函数去掉再次运行:
此时该程序就可以正常运行了。
4. 线程的退出信息
先来看看线程创建的函数:
可以看到,线程的启动函数是有返回值的,返回值为void*。然后再来看结束线程的函数:
可以看到,它也有一个参数,参数的返回值类型也是void*的。这也就是说,当一个线程在结束后,无论是因为启动函数结束,还是通过pthread_exit()结束,都会返回一个void*的返回值。再来看线程等待的函数:
线程等待的第二个参数是void*,名字是retval,这其实就是返回值的意思。因此,线程等待的第二个参数其实是一个输出型参数,可以用于接收线程的退出信息。
写出如下测试程序:
在这个程序里面,新线程返回了一个(void*)666。然后在主线程中等待线程结束。而等待函数的第二个参数传入的是一个&(void* retval),retval本身是一个指针,再取地址就是获取这个指针的地址,因此,此时就可以将&retval看做是一个二级指针。用retval接收后,再将retval强转为整形,以便看到内容。运行该程序:
可以看到,此时就接收到了退出信息666。
这样分开看大家可能不太好理解,我们换种方式来看。写出如下测试代码:
在这个程序中,p1是一个一级指针,指向a;pp是一个二级指针,指向p1。然后,再分别打印p1的地址和pp中保存的内容。运行该程序:
可以看到,此时打印出来的p1的地址和pp保存的内容都是同一个值。这就说明,二级指针pp中保存的就是p1的地址。通过强转以10进制方式打印出来了。因此我们可以知道,二级指针中,其实保存的就是一个一级指针的地址。有了这个理解,就很容易理解上面的等待函数的返回值了。因为启动函数中返回的是一个一级指针,要接收这个一级指针,就需要用一个二级指针去接收。而一个一级指针取地址,其实就可以将其看为一个二级指针。简单来讲就是,一个普通变量取地址,传过去就是一个一级指针,而一个一级指针取地址,就应该是一个二级指针。当用指针接收后,这个指针中保存的就是另一个指针的地址,因此打印该指针的内容,就是打印一个地址。
那么等待函数中的第二个参数如何理解呢?其实就是一个一级指针中保存了另一个指针的地址。用取地址传参是为了让该函数临时将它看做一个二级指针。因此,整个过程其实就可以看成如下代码:
所以,p里面保存了一个地址,类型是int*,地址内容是666。然后直接将这个地址以10进制打印出来,这个666此时就是线程退出码。
既然这种假的地址都能通过二级指针的方式被外部拿到,就意味着返回一个真的地址被返回时,外部也可以拿到这个地址,然后通过对这个地址的解引用,就能拿到对应的信息了。
在上图的程序中,就将线程内创建的一个对象的数据返回了。运行该程序:
可以看到,打印的结果就是该对象的值。
五、C++11中的线程
在C++11之后,C++也有了属于自己的线程库。在这之后,我们就可以在不调用系统接口,只使用C++提供的线程接口来创建线程。
写出如下程序:
然后再编译时,去掉“-l pthread”,不再链接线程库:
编译程序,生成可执行文件:
此时就会出现如上报错。这里就很奇怪了。在上面的程序中,我们所使用的是C++的线程接口,这里为什么会提示说找不到“pthread_create”呢?原因很简单,在linux中,如果要实现多线程,无论任何语言,都需要使用linux中的线程库。而C++的线程库,在linux环境中,本质上就是对linux的线程库的又一层封装。通过这个结论,就可以推断出,一个语言的线程库,它在哪个平台上运行,它所用的就是对该平台的线程库或线程接口的封装。
因此,在编译命令中加上“-l pthread”再编译运行:
此时就可以编译通过并正常运行了。
六、线程id问题
写出如下测试程序:
该测试程序获取了主线程和新线程的id。运行该程序:
这里的这个id是转换为了16进制来打印的。从这里来看,这个id其实和地址很像。当然,这里所返回的线程id,其实就是一个地址。那么这个地址究竟是什么呢?
大家知道,在linux中是没有真正意义上的线程的,只有轻量级进程。但是由于用户只认线程,所以在linux中就提供了一种方案,那就是提供一个“原生线程库”,让用户通过这个线程库创建和使用线程。其底层,其实就是用了clone()接口,这个接口就是linux中创建轻量级进程的接口。而一个进程的线程可以有多个,这就意味在原生线程库中可能存在大量的线程。这些线程也是需要被管理起来的,管理方法同样是结构体,用来保存线程相关属性。
由此,linux中提供的线程方案,简单来讲,就是在原生线程库中生成一个用户级线程,这个线程的相关属性被保存在库中,而执行流当然就是由OS的内核提供。因此,在linux中,用户级线程:轻量级进程 = 1 : 1
由此,当我们在linux中创建一个线程时,其实就是在原生线程库中创建了一个结构体,这个结构体用于管理线程相关属性。然后linux会对应的在OS中生成一个轻量级进程,该轻量级进程指向进程地址空间的某部分,通过虚拟地址和页表映射找到物理内存上的数据。
有了这些概念,再来看线程返回的id。
大家都学习过动态库的相关知识,应该知道,动态库其实就是存在于磁盘上,当一个进程需要使用这个动态库的内容时,OS就会将这个动态库从磁盘加载到内存中的共享区中。因此,当我们创建一个线程时,其实就是调用了原生线程库的接口,此时OS会帮我们将这个库加载到内存的共享区中。
而上文中说了,当创建线程后,会在原生线程库中生成一个结构体,这个结构体中包含了对应线程的各项属性。这些结构体在原生线程库中是顺序存储的,可以将其看为存储在一个数组中。
这个结构体处于线程库中,更准确来讲,应该是处于内存中的共享区的线程库中。因此,当我们创建完一个线程后,它所返回的线程id其实就是这些结构体在线程库中的起始地址。通过拿到的这个其实地址,我们就能够找到线程结构体所在的位置,进而对线程进行操作。
观察上图大家就可以发现,这个结构体中还包含一个“线程栈”。这其实就是每个线程的私有栈结构。所以,在linux中的线程的私有栈结构就位于内存共享区的线程库中。注意,主线程和其他线程用的不是一个栈。主线程用的是自己的地址空间中的栈区,而其他线程用的是在共享区中单独为每个线程创建的栈。
七、主线程与其他线程单独拥有共享资源
在线程中,如果在全局域定义一个变量,那么这个变量就是被所有线程所共享的。写入如下测试程序:
在这个程序里面,定义了一个全局变量,该全局变量分别被主线程和新线程使用,每次使用后都会分别--一次,运行该程序:
从运行结果上来看,主线程和新线程所使用的都是同一个g_val,和我们的预期相符。
但是,如果我们想定义一个全局变量,让每个线程都单独拥有一份该全局变量呢?方法很简单,在该全局变量前加上“__thread”关键字修饰即可:
对程序进行修改,在全局变量g_val前加上g_val修饰,为了更明显的看到对比结果,让主线程不再--。运行该程序:
可以看到,此时主线程和新线程所使用的就不再是同一个g_val了,而是各自一份。
因此,“__thread”关键字的作用就是“将一个内置类型设置为线程局部存储”。如果大家自己对比该程序的两次对比就会发现, 当修改为线程局部存储后,g_val的地址就变得非常大。原因就是线程局部存储是在共享区的线程库中。无论是主线程还是其他线程,都需要在线程库中生成对应的结构体,也就都会有这个区域。当加上“__thread”后,对应数据就会被存储到这个区域中。
八、自行封装一个简单的线程类
了解了linux下的线程接口,就可以开始尝试自己封装一个简单的线程类了。
#include<iostream>
#include<string>
#include<cstring>
#include<functional>
#include<pthread.h>
#include<cassert>
class Thread;//类声明
struct Context//获取线程类的this指针和_args的一个结构体
{
Context()
: _this(nullptr), _args(nullptr)//初始化为空
{}
~Context()
{}
Thread* _this;
void* _args;//传给启动函数的参数
};
//用线程库接口封装一个可供使用的线程类
class Thread
{
typedef std::function<void*(void*)> func_t;//启动函数重命名
const int num = 64;
public:
Thread(func_t func, void* args, int number)//构造函数,将需要的参数传入并创建线程
:_func(func), _args(args)
{
char namebuffer[num];
snprintf(namebuffer, sizeof(namebuffer), "thread-%d", number);
_name = namebuffer;//生成线程名字
Context* ct = new Context;//创建一个对象,保存this指针和_args
ct->_this = this;
ct->_args = _args;
int n = pthread_create(&_tid, nullptr, start_routine, (void*)ct);//在这里,并不支持直接使用C++的函数重命名,解决方法有两个,
assert(n == 0); //可以在函数重命名处直接void*(*func_t)(void*);另一种就是再次封装
(void)n;
}
static void* start_routine(void* args)//此处不能直接用,要修改为static,因为启动函数只能有一个
{ //void*参数,而类中的函数自带一个this参数
//return _func(args);不能直接用,因为static函数没有this指针,无法直接访问类中的成员函数
Context* ct = static_cast<Context*>(args);//类型强转
void* ret = ct->_this->run(ct->_args);
delete ct;//销毁对象
return ret;
}
void* run(void* args)//提供访问类成员的函数
{
return _func(args);
}
void join()//等待函数
{
int n = pthread_join(_tid, nullptr);
assert(n == 0);
(void)n;
}
~Thread()//析构函数,此时什么都不用做
{}
private:
std::string _name;//线程名字
pthread_t _tid;//线程id
func_t _func;//启动函数
void* _args;//线程参数
};
所有的实现时需要注意的细节在代码中都已经写好了,这里就不再赘述。