【Linux】多线程 --- 线程概念 控制 封装-2

简介: 【Linux】多线程 --- 线程概念 控制 封装-2

二、线程控制

1.创建一批线程


1.

在谈论创建一批线程之前,我们先来拓展的认识一下下面这两个接口。

clone其实是一个创建linux线程的系统调用接口,但我们知道在linux中是没有线程这个概念的,只有轻量级进程这个概念,所以linux中fork创建子进程底层调用的同样是clone,而创建轻量级进程的底层系统调用接口也还是这个clone。因为对于linux来讲,创建轻量级进程和创建线程主要区别其实就在于,创建出来的PCB执行流是否要共享地址空间,如果要共

享,那linux只需要创建PCB就可以了,这其实就是创建轻量级进程。如果不共享,那就不仅仅需要创建PCB了,还需要创建新的地址空间以及页表,完成对应的映射工作等等,而这其实就是创建进程。


另外linux还提供了另一个接口vfork,这个进程创建出来的子进程和父进程是共享地址空间的,所以虽然他叫做vfork,但其实他创建出来的就是轻量级进程,也就是linux下的"线程",vfork创建出来的子进程和父进程同样共享绝大部分资源,也契合线程的其他属性。

ad67a4a13bda4a1798f90e4dc393a38c.png

2.

创建一个线程在线程概念部分就做过了,比较简单没什么含金量,所以在线程控制这里选择创建一批线程,来看看多个线程下的进程运行情况。

在线程的错误检查这里,并不会设置全部变量errno,道理也很简单,线程出错了,那其实就是进程出错了,错误码这件事不应该是我线程来搞,这是你进程的事情和我线程有什么关系?所以线程也没有理由去设置全局变量errno,他的返回值只表示成功或错误,具体的返回状态,其实是要通过pthread_join来获取的!

31bcd94cd4ce42fa9d9cf4da8168b5e0.png


3.

创建一批线程也并不困难,我们可以搞一个vector存放创建出来的每个线程的tid,但从打印出来的新线程的编号可以看出来,打印的非常乱,有的编号还没有显示,这是为什么呢?(我们主观认为应该是打印出来0-9编号的线程啊,这怎么打印的这么乱呢?)

其实这里就涉及到线程调度的话题了,创建出来的多个新线程以及主线程谁先运行,这是不确定的,这完全取决于调度器,我们事先无法预知哪个线程先运行,所以就有可能出现,新线程一直没有被调度,主线程一直被调度的情况,也有可能主线程的for循环执行到i等于6或9或8的时候,新线程又被调度起来了,此时新线程内部就会打印出创建成功的语句。所以打印的结果很乱,这也非常正常,因为哪个线程先被调度是不确定的!86794371648e49848febe4b1a82f6eb7.png


但如果我们每创建出来一个新线程,我们先让主线程sleep 1秒,等等新线程,让新线程先运行一下,然后再继续创建线程,这样的话,打印出来的就和我们主观想的结果一样了,打印的就不会乱了。

9fd26236a0fd46869ab2d530d125d65f.gif


2.线程的终止和等待(三种终止方式 + pthread_join()的void**retval)


1.

再谈完线程的创建之后,那什么时候线程终止呢?所以接下来我们要谈论的就是线程终止的话题,线程终止总共有三种方式,分别为return,pthread_exit,pthread_cancel

我们知道线程在创建的时候会执行对应的start_routine函数指针指向的方法,所以最正常的线程终止方式就是等待线程执行完对应的方法之后,线程自动就会退出,如果你想要提前终止线程,可以通过最常见的return的方式来实现,线程函数的返回值为void*,一般情况下,如果不关心线程退出的情况,直接return nullptr即可。

和进程终止类似的是,除return这种方式外,原生线程库还提供了pthread_exit接口来终止线程,接口的使用方式也非常简单,只要传递一个指针即可,同样如果你不关心线程的退出结果,那么也只需要传递nullptr即可。

4c2e41e2326f461e90bd561b28f1cc6b.png

e5ddc701357f4a40a6d2e53513b9a3de.png


2.

谈完上面两种线程终止的话题后,第三种终止方式我们先等会儿再说,与进程类似,进程退出之后要被等待,也就是回收进程的资源,否则会出现僵尸进程,僵尸的这种状态可以通过ps指令+axj选项看到,同时会产生内存泄露的问题。

线程终止同样也需要被等待,但线程这里没有僵尸线程这样的概念,如果不等待线程同样也会造成资源泄露,也就是PCB资源未被回收,线程退出的状态我们是无法看到的,我们只能看到进程的Z状态。

原生线程库给我们提供了对应的等待线程的接口,其中join的第二个参数是一个输出型参数,在join的内部会拿到线程函数的返回值,然后将返回值的内容写到这个输出型参数指向的变量里面,也就是写到我们用户定义的ret指针变量里,通过这样的方式来拿到线程函数的返回值。

通过bash的打印结果就可以看到,每个线程都正常的等待成功了。

e1806d3020a94dd19e017dc6cfad0e87.png

3.

有些人可能觉得join的第二个参数不太好理解,所以这里在细说一下这个部分,以前如果我们想拿到一个函数中的多个返回值,但由于函数的返回值只能有一个,所以为了拿到多个返回值,我们都是在调用函数之前,定义出想要拿到的返回值的类型的变量,然后把这个变量的地址传给需要调用的函数,这样的函数参数我们称为输出型参数,然后在函数内部会通过解引用输出型参数的方式,将函数内部的某个需要返回给外部的值拷贝到解引用后的参数里面,那其实就是修改了我们函数外部定义的变量的值。


这里不好理解的原因其实是因为二级指针,我们想要拿到的线程函数的返回值是一个指针,不再是一个变量,所以在调用join的时候,仅仅传一级指针是不够的,我们需要传一级指针变量的地址,让join内部能解引用一级指针变量的地址,拿到外面的一级指针内容并对其修改。876c3488c9ae479fa23f86173224f1bd.png


876c3488c9ae479fa23f86173224f1bd.png

4.

我们可以做一个测试,我们将一个数字100强制转为void*类型的指针并返回,那么pthread_join的第二个参数就应该能拿到这个返回值,所以在调用join之后,将ret指针的值强转成long long的8字节整型,然后看看是否join能拿到线程函数的返回值。通过bash的打印结果就可以看到,确实join能依靠他的第二个参数获取到线程函数的退出状态。

4c53e56d25894e41a15471bb94f13139.png


上面的测试较为简单,我们其实还可以让线程函数返回一个结构体指针,看看join能否拿到结构体指针呢?通过bash的输出结果可以看到,ThreadReturn类型的指针ret也可以拿到线程函数的返回值,线程函数的返回值也是一个ThreadReturn类型的指针,我们拿到了ret指向的exit_code和exit_result的值


下面的代码有内存泄露,在等待成功之后,要记得delete ret指针,否则ThreadRetuen结构体不会被回收!

6a49a27bb39b4478b564fc5de16243bb.png


5.

在了解join拿到线程函数的返回值之后,我们再来谈最后一个线程终止的方式pthread_cancel,叫做线程取消。首先线程要被取消,前提一定得是这个线程是跑起来的,跑起来的过程中,我们可以选择取消这个线程,换个说法就是中断这个线程的运行。

如果新线程是被别的线程取消的话,则新线程的返回值是一个宏PTHREAD_CANCELED,这个宏其实就是把-1强转成指针类型了,所以如果我们join被取消的线程,那join到的返回值就应该是-1,如果线程是正常运行结束退出的话,默认的返回值是0.

我们让创建出来的每个新线程跑10s,然后在第5s的时候,主线程取消前5个线程,那么这5个线程就会被中断,主线程阻塞式的join就会提前等待到这5个被取消的线程,并打印出线程函数的返回值,发现结果就是-1,再经过5s之后,其余的5个线程会正常的退出,主线程的join会相应的等待到这5个线程,并打印出默认为0的退出结果。0cb95572a7884c5e9fd69a11498c0286.png


0cb95572a7884c5e9fd69a11498c0286.png

3.初步认识原生线程库(在linux环境,C++11线程库底层封装了POSIX线程库)


1.

我们知道C++11也是有自己的线程库的,C++11的线程库是C++标准库的一部分,它提供了一种跨平台的线程管理接口,可以在不同的操作系统上使用。

在windows平台,C++11的线程库是基于Windows线程库实现的,因此它可以直接调用Windows线程库提供的底层线程管理接口。

在linux平台,C++11的线程库则需要使用linux提供的POSIX线程库来实现,C++11的线程库可以使用POSIX库来实现跨平台的线程管理。

所以,在Windows平台上,C++11的线程库底层封装了Windows线程库,而在Linux平台上,它底层封装了POSIX线程库(pthread)。这使得C++11的线程库可以在不同的操作系统上使用,并且提供了一种跨平台的线程管理接口。


2.

下面代码就是C++11形式的线程管理代码,这段代码的好处就是它可以跨平台运行,无论是在linux还是在windows环境下这段代码都可以跑,因为C++11的线程库底层封装了各个操作系统的线程库实现,这使得我们能够通过C++11形式的线程管理方式,写出跨平台的代码,这是C++11线程库的优势。

当然我们前面所写的线程管理代码都是用原生的POSIX线程库写出来的,并且是在对应的linux环境下面运行的,所以软件层次的调用就会少很多,程序的运行效率就会高很多,这是POSIX原生线程库的优势。只不过我们用原生线程库写出来的代码无法跨平台运行,只在linux环境下能跑。

43624d26de02402898a93cc916d5b5fc.png



4.线程的分离(若要进行分离,推荐创建完线程之后立马设置分离)

1.

上面我们谈过了线程终止和等待的话题,我们知道如果不等待线程的话,会造成内存泄露的问题产生,所以要通过join的方式来等待线程,如果关注线程的退出状态,则可以通过join的第二个参数来拿到对应的线程函数返回值。

那如果我们根本就不想等待这个线程呢?在进程那里我们可以通过设置SIGCHLD信号处理方式为SIG_IGN的方式来让操作系统自动帮我们回收子进程运行结束后的资源。或者如果进程不想阻塞式等待的话,也可以通过非阻塞式等待,以轮询的方式来检测子进程的状态,发现为Z状态时,waitpid就会回收子进程的资源了。

但在线程这里是没有非阻塞式等待这样的概念的,你要么就阻塞式等待线程,要么就别等待线程!


2.

新创建出来的线程默认状态是joinable的,也就是说你必须通过pthread_join去等待线程,否则就会造成内存泄露。


但如果我们压根就不想等待线程,那调用pthread_join就是一种负担,这个时候我们就可以通过分离线程的手段,来告诉操作系统,现在我这个线程要和进程分离了,我不再共享进程的地址空间了,我也不要进程的任何资源了,我们俩人以后就形同陌路,互不相干了!操作系统你现在就把我回收吧,我已经和进程没有任何关系了!


所以在设置线程为分离状态后,操作系统会立即回收线程的所有资源,而不需要等待线程自动退出或者是手动来释放资源,表示我们现在已经不关心这个线程了!


joinable和detach是线程的两个对立的状态,一个线程不能既是joinable又是分离的,并且如果线程被设置为detach,那么就不可以用join来等待线程,否则是会报错的!


3.

设置线程分离的接口pthread_detach使用起来比较简单,这里也就不做介绍了。

设置线程为分离状态,可以是线程自己设置自己为分离状态,也可以是其他线程来设置他为分离状态。下面代码中新线程自己设置自己为分离状态,但实际上没有sleep(3)这行代码的话,可以看到运行结果是新线程正常运行,在5s之后join还等待成功了!并且没有报错,这是怎么回事啊?


其实主要原因还是在于线程调度,新线程和主线程谁先被调度运行我们是不确定的,所以就有可能出现新线程还未执行pthread_detach设置自己未分离状态之前,主线程已经执行到pthread_join了,已经开始阻塞式等待新线程了!也就是说在执行join的时候,join是不知道新线程是分离状态的,还以为他是joinable的呢,这就会导致join函数一直阻塞式等待,在新线程退出后,join会等待成功,默认返回码是0.


如果想要解决这种问题,主要还是得在调用join之前,让join知道他等待的线程是分离状态的,这样的话join就会报错了,所以加上sleep(3)这行代码就是为了先让主线程停一下,等调度器调度新线程,新线程设置自己为分离状态之后,join就知道他等待的线程是分离状态的了,此时join函数就会报错,Invalid argument。


9184cabc326f48b4bf444fbbf31fd88e.png


4.

除上述那种主线程等待几秒,让主线程知晓新线程是分离状态的这种方法外(这种方法看起来有点挫),更为推荐的一种做法是,在创建新线程之后,立马就设置新线程为分离状态,也就是让其他线程设置新线程为分离状态,而不是让他自己设置自己为分离状态。

如果是这样的话,那在新线程运行结束之后,主线程一定是知道他为分离状态的,因为创建线程之后的第一步工作就是设置线程为分离状态,此时如果调用join进行等待,那就会直接报错。所以,设置为分离之后,主线程就可以自己干自己的事情了,无须担心创建出来的线程有内存泄露的问题产生!933c6fe8e01f4632b9cd049dc29e6a88.png


5.揭示用户级线程tid究竟是什么?(映射段中线程库内的TCB的起始地址)

1.

我们知道linux中没有真正意义上的线程,所以需要原生线程库来提供创建线程的接口,那你当前的进程可能在使用原生线程库,其他进程有没有可能也在同时使用呢?那如果其他进程也在使用原生线程库,原生线程库中就会存在多个线程,那库中的多个线程要不要被管理起来呢?当然要!管理就得先描述,再组织,那描述出来的结构体是什么呢?其实就是pthread_create接口中的第二个参数指向的联合体,这个结构体是在库这一软件层面所创建的,但其实这只是线程的一小部分属性,大部分的属性都在linux的内核中。

1eef835ab74947129f008c05a3684e3b.png


2.

除线程库要在用户层创建一个描述线程的数据结构外,实际操作系统还会给用户层的TCB创建出来对应的轻量级进程内核数据结构,进行内核中轻量级进程的管理。


所以可以认为,线程是POSIX库中实现了一部分,操作系统中实现了一部分。每当我们创建一个线程时,库就要帮我们在用户层创建出对应的线程控制块TCB,来对库中的多个线程进行管理,同时操作系统还要在对应的创建出轻量级进程。所以,Linux用户级线程 : 内核轻量级进程 = 1:1。


用户关心的线程属性在用户级线程中,内核提供轻量级进程(线程)的调度!

内核中创建轻量级进程调用的接口就是clone,它可以帮助我们创建出linux认为的"线程"。8314135ed02f454eb9ef911eeea33baf.png

3.

在知道用户层线程和内核轻量级进程之后,我们来详细谈一下程序是如何使用原生线程库的。


与静态链接不同的是,动态链接只会把可执行程序需要用到的动态库的库函数的偏移地址拷贝到可执行程序里面,动态库中所有库函数在动态链接时,都采用的是这种起始地址+偏移量的方式来进行相对编址。


然后CPU在调度可执行程序时,从物理内存读取代码时,发现有外部的物理地址(这个外部的物理地址就是动态链接时,链接到程序中的库函数的偏移地址)此时CPU不会继续执行我们的代码,而是转而去加载这个物理地址所对应的动态库!


在将磁盘上的动态库文件加载到物理内存的过程中,操作系统会读取动态库文件的头部信息,确定好动态库的大小,布局等等信息。然后操作系统会为该动态库分配一段虚拟地址空间,并将动态库文件中的代码段、数据段等信息都映射到该虚拟地址空间中,这个区域就是夹在栈和堆之间的映射段。在映射工作完成之后,库中函数的起始地址就立马被确定了,通过起始地址+偏移量的方式,就可以在映射段中确定出程序所使用的库函数代码的具体位置,CPU就会读取并执行映射段中库函数代码,这样动态库就会被使用起来了。


在CPU读取并执行pthread_create代码的时候,就会在映射段中创建每个线程的线程控制块TCB,每个线程的基本属性都会作为一个个的字段存放在这个TCB中,例如线程私有栈,而在Linux中,pthread_create底层会调用clone接口,clone会创建好线程控制块,其中clone的第二个参数void *child_stack就是线程的私有栈的属性。所以线程所使用的栈是在映射段中操作系统给分配的,而主线程所使用的栈是在栈区上操作系统给分配的。


而映射到映射段中的动态库内肯定会存在多个线程,所以线程库会使用一个数组来管理这些TCB,而每个线程的LWP值其实就是线程对应的TCB结构体的起始地址,而我们之前一直所说的tid其实就是TCB结构体的起始地址,每个TCB都会有自己的起始地址,这样能够很好区分开来每个TCB的地址空间布局。


像之前所使用的join函数的第一个参数,也就是tid,他就是TCB的起始地址,也就是指向TCB结构体的指针,而线程函数的返回值实际会写到TCB结构体中的某一个字段,join函数需要tid这个地址,实际就会通过这个结构体指针从TCB中拿到表示线程函数返回值的那个字段的内容。然后将其写到join的第二个参数 void **retval里面。


(由于程序无法直接读取物理内存上的代码和数据,所以需要将动态库文件的各个段的信息全部映射到虚拟地址空间的映射段上,这样CPU才能访问虚拟地址空间上程序的所有代码,包括代码中所使用的第三方库的代码,因为这些数据都会被映射到虚拟地址空间上,操作系统会在加载动态库的时候,完成动态库到虚拟地址空间上映射段的映射工作)


并且我们现在也能回头去理解一些东西了,例如为什么叫用户级线程库,当然是因为线程库会被映射到虚拟地址空间的映射段啊,而映射段不就是在用户空间吗?线程库的代码都是跑在用户空间的上的,所以线程库也叫用户级线程库

c336527cf8374638a3aab97d50ce44c7.png


clone的第二个参数子栈对应的就是线程的私有栈属性。137f382053c644bea48c310909e0e14a.png



6.线程的局部存储(介于全局和局部变量之间的,线程特有的一种存储方案)



1.

接下来我们再来谈另外一个话题,叫做线程的局部存储。

我们定义一个全局变量g_val,然后让新线程和主线程分别都打印这个全局变量的地址和值,然后新线程不断++这个g_val的值。

dfcbc5b279aa45c98ab7e092eb879bae.png

当这个变量是普通的全局变量的时候,新线程修改,主线程同样也可以看到修改后的变量的值,并且两个线程打印出来的变量的地址也是一样的。

caf5bac021c1412293fce097bc9988f0.png

当变量被__thread关键字修饰过后,该变量变为线程局部存储的变量,每个线程都会独立拥有该变量,所以两个线程打印出来的地址是不一样的,并且新线程打印的值是以1为单位逐个增加的,而主线程打印的值不会变化。由此可见这个变量确实是线程局部存储的,每个线程都有自己独立的这份变量。

ad7e0ebb5f0a4442aad5d98718892309.png



2.

但是为什么前后打印出来的地址差别这么大呢?线程局部存储的变量地址那么一长串,而原来的那个全局变量地址只有那一小串。

原来的地址是已初始化数据段的地址,而线程局部存储之后,该地址变为映射段的地址,我们知道地址空间从下到上地址在逐步的增加,变化也会越来越大,映射段和已初始化数据段间隔还是比较大的,所以地址的差别同样也会很大。


3.

线程局部存储有什么用呢?

如果你给线程定义的局部属性不想放在堆上,也不想放在栈上,而是想在程序编译好的时候,天然的就给每个线程独立的分配私有的变量空间,那么你就可以使用线程局部存储关键字__thread来定义每个线程独立拥有的变量,设置线程的私有属性。

这种局部存储是介于全局变量和局部变量之间的一种线程特有的存储方案!


三、线程封装(面向对象)

1.组件式的封装出一个线程类(像C++11线程库那样去管理线程)


1.

我们并不想暴露出线程创建,终止,等待,分离,获取线程id等POSIX线程库的接口,我们也想像C++11那样通过面向对象的方式来玩,所以接下来我们将POSIX线程库的接口做一下封装,同样能实现像C++11线程库那样去管理我们的线程,这个类就像一个小组件似的,包含对应的.hpp文件就可以使用,使用起来很舒服。


2.

线程类需要的成员变量有执行的函数_func,格式化后的线程名_name,线程独立的_tid,线程函数的参数_args。其中_func我们用包装器来实现,这样外部在构造线程对象的时候,就可以传函数指针,lambda表达式,仿函数对象等等。

构造函数的参数是包装器类型的可调用对象,以及线程函数的void *参数,外加一个线程的编号,用于区分打印出来的线程。我们可以将线程名进行格式化处理存储到buffer里面,buffer的内容就是成员变量_name的内容,线程编号的参数就会在这个地方用到。然后就可以调用pthread_create来创建出线程,并做好查错处理。

但在调用pthread_create的时候,其实会出问题,因为第三个函数指针所指向的函数的返回值是void *,参数也是void *的。而只要我们将线程函数start_routine写到类内,默认的形参第一个位置会有一个缺省参数this指针,所以在调用start_routine的时候,就会产生类型匹配错误的问题。我们可以通过将函数设置为静态成员来解决这个问题,因为static修饰的类成员是没有this指针的,这样类型就可以匹配了。

但随之又会引出新的问题,start_routine想要调用_func的时候,其实是调不到的,因为_func是非静态成员变量,必须得是有this指针的非静态成员才能调用非静态成员_func,所以这里也会出现问题。一种解决方案是将_func也搞成静态的,虽然这样可以调用到_func,但_func就属于整个类了,那创建出来的所有线程执行的方法就都是一样的了,这样不太好。另一个解决方案是将start_routine直接搞成友元函数放到类外面(友元函数既可以访问类的非静态成员也能访问类的静态成员)这样他就能访问_func了,但这样也不太好,因为友元会破坏类的封装性。

现在我们回到最本质的问题上来,由于start_routine是静态成员函数,没有this指针,无法调用到_func,那我们就可以搞一个大号的结构体,让结构体存储this指针,也就是在调用pthread_create之前搞出Context类型的结构体,然后把这个结构体指针作为start_routine的参数传给start_routine函数,在start_routine间接的通过ctx结构体中的this指针来调用_func,但因为_func是private的,所以再增加一个Thread类成员函数run,在run中调用private修饰的_func可调用对象,这样就可以实现线程的创建和运行了。

线程的等待也比较简单,直接调用pthread_join即可完成线程的回收工作。析构函数什么都不用写,因为编译器会自动调用string类的析构函数,所以不会出现内存泄露的情况。


补充知识:就算我们写的是空的析构函数,线程对象销毁时会调用这个空的析构函数,编译器还是会调用string的析构函数完成_name对象的内存资源的回收的

4694dc8c2ac445c38819078be5f4f2f6.png



3.

使用线程的时候,我们可以通过智能指针来使用,构造智能指针的时候,需要调用线程的构造函数,只要调用了线程的构造函数,线程就跑起来了,跑完之后,就可以通过智能指针来调用join函数完成线程资源的回收了。


880d0aa8d5a240c98f4e3400cfa4fdda.png

下面是代码运行结果

3faee482a32745809435e415260df163.png











































相关文章
|
2月前
|
资源调度 Linux 调度
Linux C/C++之线程基础
这篇文章详细介绍了Linux下C/C++线程的基本概念、创建和管理线程的方法,以及线程同步的各种机制,并通过实例代码展示了线程同步技术的应用。
33 0
Linux C/C++之线程基础
|
2月前
|
Ubuntu Java Linux
Linux操作系统——概念扫盲I
Linux操作系统——概念扫盲I
47 4
|
2月前
|
安全 Linux
Linux线程(十一)线程互斥锁-条件变量详解
Linux线程(十一)线程互斥锁-条件变量详解
|
3月前
|
Linux Python
linux 封装 python
linux 封装 python
23 0
|
3月前
|
Linux Python
Linux 下封装 Python
Linux 下封装 Python
19 0
|
4月前
|
存储 缓存 Linux
在Linux中,文件系统概念是什么?
在Linux中,文件系统概念是什么?
|
4月前
|
存储 安全 Linux
在Linux中,用户和组的概念是什么?
在Linux中,用户和组的概念是什么?
|
4月前
|
Linux 持续交付 虚拟化
在Linux中,Docker和容器虚拟概念是什么?
在Linux中,Docker和容器虚拟概念是什么?
|
4月前
|
负载均衡 Linux 调度
在Linux中,进程和线程有何作用?
在Linux中,进程和线程有何作用?
|
2月前
|
存储 消息中间件 资源调度
C++ 多线程之初识多线程
这篇文章介绍了C++多线程的基本概念,包括进程和线程的定义、并发的实现方式,以及如何在C++中创建和管理线程,包括使用`std::thread`库、线程的join和detach方法,并通过示例代码展示了如何创建和使用多线程。
55 1
C++ 多线程之初识多线程