在上一篇博客中我介绍了Linux中的线程是什么样的,就如同进程可以通过
fork创建,可以被终止,可以退出一样,线程也可以被我们用户控制,这
篇博客我会介绍线程的控制,并且基于线程的控制所产生的一些问题进行
解决这些问题
线程的控制
1. 线程的创建
我们要学会对线程的控制,首先得创建出来一个线程,所以我们会认识一个接口:
这就是我们创建线程的一个接口,可以看出它是三号手册中的,并不是系统调用,这一点之后会从多方面来解释它。
它的第一个参数就是线程的id,类型是ptread_t,这是一个输出型参数需要用户自己传入,然后它给你带出线程的id。
第二个参数是有关于线程的属性的参数,我们现在不谈论,传参时传空指针就可以。
第三个参数是一个函数指针,这个函数的参数是void*,返回值也是void*,这个函数也就是线程要执行的函数。
这第四个参数就是第三个参数中函数的参数,它是以这种方式来传的。
下面我们就可以来编写一简单的代码了(其实也是我们上一章博客中所写的):
当我们写好代码之后直接使用g++编译的时候会出现下面的情况:
这其实是因为pthead_create函数,它不是系统调用,它是存在于库中的,但是它也不是C/C++标准库,所以我们在编译的时候要加上这么一个选项:
而pthread库它有一种叫法,叫做系统原生库,顾名思义就是只要有操作系统,那么就一定会携带这个库。
那么这个库为什么会形成单独的库,而不是和系统代码在一起呢?我们上一章说过Linux中不存在真正意义上的线程,只存在轻量级进程,而对于用户来说,我们刚接触Linux的话哪能知道什么是轻量级进程,我们只知道线程,所以操作系统就必须实现出关于线程的一套接口便于用户使用,这也就会形成了一个pthread库了,不和系统代码在一起是因为Linux中真正意义上根本没有线程这个说法。
a. 多线程的创建
既然能创建一个线程,那我们就能够创建多个线程:
在这段代码所演示的结果中我们可以看到几个现象:
task_thread函数被多个执行流同时执行,那就说明这个函数被重入了。
CPU对于线程的调度也是随机的,这一点是理所应当的,因为我们刚开始解除CPU的调度的时候,它的调度就不是完全按顺序来的。
假如现在我们对四号线程进行除0的操作:
可以看到一个线程出现异常之后,所有的线程都会被终止,这也印证了我们上一篇博客中所说的。
但是我们发现我们上面的代码有些挫,并且我们要知道线程创建的接口中的那个函数的参数是void*的,这就意味着我们可以传任意类型的参数。那么我们的代码可以是这样的:
这里打印出现不规整的现象也是因为CPU调度的时候时间片到了之后切换线程,然后导致输出时,语言缓冲区中的数据出现了错乱。这也说明该函数中含有输出打印函数,这个输出函数也是不可重入的。
我们此时再让这个主线程打印一下线程的id,同时我们再查看一下线程的lwp:
我们发现线程的id和lwp竟然不是同一个值,而这其中的原委我会在之后讲述,其实线程id它就是一个地址而已。
在这里我们介绍一个接口,它可以获取调用线程的线程id:
我们可以看到主进程中打印的tid和线程函数中所打印的线程id是相等的,并且我们也可以打印主线程的id。
2. 线程的控制
接下来我们要来认识一些关于线程控制的接口。
a. 线程的终止
我们首先要学会线程的终止
凭借return可以直接终止进程:
但是要注意exit函数是用来终止一个进程的,如果把它作用在线程中,它会终止整个线程:
除了利用return之外,我们还可以使用pthread库中的函数pthread_exit:
它的参数跟return 一样能返回函数的返回值:
b. 进程的等待
在对进程的认识中进程退出后如果不回收的话,就会进入僵尸状态,而线程也是如此,但是它不是跟进程一样的僵尸状态,而是是类似于僵尸问题。所以就有了线程的回收函数:
这个函数就能够回收指定线程id的线程,回收成功返回0,回收失败返回一个错误数字:
我们在前面只是提及了一下线程执行函数的返回值,但是并没有说怎么获取,我们可以看到,我们是没有办法在主线程中直接得到这个线程函数的返回值的,所以就有了pthread_join的第二个参数。
线程函数的返回值
我们看到线程函数的返回值类型是void* 类型的,而pthread_join的第二个参数是void**的,这其实就是输出型参数,可以回想一下,当我们函数中有输出型参数时,传的其实是这个参数的指针,所以这里才是void*。当使用pthread_join成功回收线程之后,它的第二个参数就会指向线程函数的返回值:
就如同我们线程函数可以传任意类型一样,意味着我们的返回值也可以返回任意类型:
我们看到确实能够返回任意类型,但是这里的线程id的打印好像有问题,我上面说线程id就是一个地址,这个机器是64位的,所以使用int来存储地址会溢出所以这里我们更改一下:
这里可能会有人有问题,假如线程出了异常怎么办?这个等待函数中好像没有waitpid中的status啊,其实无需担心因为线程异常,整个进程都会退出,这就跟这个线程没有关系了,而是进程的问题了。所以编写线程代码时尽量不要让它出现异常。
c. 线程的分离
我们在使用线程的时候有时候并不需要它返回信息,只需要帮我们完成任务之后退出就可以了,所以为了取消pthread_join的消耗,我们可以将这个线程分离:
这个函数会将指定线程id的线程与主线程分离,然后就不需要关心它的返回和回收它了,这个线程会自动被操作系统回收,且如果主进程是不可以join该线程的。说是分离但其实这些线程还是共享资源,出了异常还是会终止所有进程:
当然这个线程分离函数可以让其它线程分离目标线程,也可以自己分离自己。
d. 线程的取消
这是线程终止的第三种方式:
我们可以来看看一个线程被取消之后,join它会发生什么:
我们看到线程函数种返回的是10,但是结果是-1。
这是因为当线程被取消之后,操作系统会将线程函数的返回值设为-1,而这个-1其实是一个宏:
我们再看线程分离之后能否被取消,以及取消之后能否被join呢?
我们能看到线程分离之后仍就能被取消,但是仍然无法join。
较深层次了解pthread库
我们在了解pthread库中的各种接口,但是这些接口都是三号手册中的接口,而它也不存在于C/C++标准库中,我说它其实是原生系统库。
我们这次从用户的视角跳的更高一些,以俯视的视角来认识一下Linux中的线程。我们知道Linux种并没有真正意义上的线程,它是使用进程模拟实现的线程,从而有了轻量级进程,但是对于用户而言,用户在刚使用Linux的时候可能并不认识什么是轻量级进程,他们只认识线程。所以Linux就必须提供关于能够呈现出线程行为的一套体系。但是因为Linux中没有线程,这个就需要在用户层和系统层之间添加一层,使用这一层来让轻量级进程呈现出线程的行为:
所以,一方面pthread需要向下封装Linux中的轻量级进程,一方面要对用户提供各种线程的接口。
而我们也知道,系统中会存在许多的线程,要完成以上两点,pthread库也必须能够管理这批被模拟成线程的进程,先组织再描述,pthread库中一定会有像struct tcb这样的结构体,这个结构体肯定封装着一个lwp,因为轻量级进程与被模拟出来的线程是一对一的。
而且线程拥有着自己的属性,比如上下文,比如栈空间,这些一定是要被以上的结构体纳入其中的。
在Linux中有这样一个系统调用:
这个是用来创建轻量级进程的系统调用,它也是创建进程整体的系统调用(fork的底层就是它),具体要创建哪一个这一点可以通过它的第三个参数flag实现。
它的第一个参数就是我们线程控制中创建线程所要执行的函数。第二个函数就是线程所使用的栈空间,可以看到它是一个指针。
那么pthread库又是怎么管理线程的呢?
当我们的程序中使用了pthread库之后,我们的程序在执行之后,pthread库也会被加载到内存中,同时与进程的共享区通过页表建立映射,但是我们说了,系统中会有很多的线程,也会有很多的进程,所以一定会出现多个进程地址空间指向跟pthread库建立映射的,这样pthread库就需要管理系统中的所有的线程:
而在实际场景中,pthread库是这样组织线程属性的:
在当线程库与地址空间建立映射之后,线程库中就会创建并初始化出关于线程的各种属性集,然后当再次创建新线程的时候,就会在地址上紧挨着上一个新线程属性集在创建初始化,这样看起来对线程的管理就像对一个数组增删查改一样。
在struct pthread结构体中一定存在这样的字段void* retval来存储线程函数的返回值,从而让pthread_join能够获取返回值。
地址空间中栈只有一个,那么线程的栈空间又是如何分配呢?要知道前面介绍的clone接口中关于栈空间的参数是一个指针,所以线程栈空间可以是在堆上创建,第一个线程使用的就是地址空间的栈区,而新线程的栈空间一般是建立在堆上。
我们看到无论是pthead库的函数接口还是pthread对于线程的组织以及管理的数据,它都是需要在进程的地址空间中的用户区建立的的,所以Linux中的线程也叫用户级线程。
理解高级语言中的线程库
我们都知道现在的大部分的编程语言,诸如C++、java、python都支持了对应的线程库,我们现在就来简单的使用一下C++的线程库:
当我们编译好它之后再运行它,发现它运行不了,而当我们的编译选项中加上-pthread后,程序能够正常运行:
由此得知,它也只不过是封装了pthread库从而实现了自己的线程方法而已。
线程局部存储
我们使用一段代码来解释它是什么:
这段代码展示了,线程之间是可以共享全局变量的,但是我们在g_flag前加一个这个选项,:
这个全局变量好像变成每个线程私有的了,__thread是一个编译选项,它会将修饰的全局变量从所有线程共享的状态,变成每个线程独有的一个变量,而这个变量的存储也从全局数据区到了线程局部存储。
最后我在提供两个知识点,可以自行下去验证,在多线程中,线程是可以创建子进程的,这个子进程仍是该线程所属线程的子进程,线程也是可以使用exec*类的接口进行程序替换的,但是一般不建议,因为一个线程切换了代码块之后,可能会影响其他线程的代码执行。