嵌入式 Linux进程间通信(十一)——多线程简介

一、线程简介

    线程有四种:内核线程、轻量级进程、用户线程、加强版用户线程

1、内核线程

    内核线程就是内核的分身,一个分身可以处理一件特定事情。内核线程的使用是廉价的,唯一使用的资源就是内核栈和上下文切换时保存寄存器的空间。支持多线程的内核叫做多线程内核(Multi-Threads kernel )。

2、轻量级进程LWP

    轻量级进程(LWP)是一种由内核支持的用户线程,是基于内核线程的高级抽象,只有先支持内核线程,才能有轻量级进程LWP。每一个进程有一个或多个 LWP,每个LWP由一个内核线程支持,即一对一线程模型

    每个LWP都与一个特定的内核线程关联,因此每个LWP都是一个独立的线程调度单元。即使有一个LWP在系统调用中阻塞,也不会影响整个进程的执行。

    轻量级进程具有局限性。首先,大多数LWP的操作,如建立、析构以及同步,都需要进行系统调用。系统调用的代价相对较高:需要在用户空间和内核空间中切换。其次,每个LWP都需要有一个内核线程支持,因此LWP要消耗内核资源(内核线程的栈空间)。因此一个系统不能支持大量的LWP

wKiom1eEUIDQepIvAAGJfZj6spI178.png

3、用户线程

    轻量级进程LWP虽然本质上属于用户线程,但LWP线程库是建立在内核之上的,LWP的许多操作都要进行系统调用,因此效率不高。用户线程指的是完全建立在用户空间的线程库,用户线程的建立,同步,销毁,调度完全在用户空间完成,不需要内核的帮助。因用户线程的操作是极其快速的且低消耗的。

wKioL1eEUJ6TLr7yAACQvabotG4811.png

    进程中包含线程,用户线程在用户空间中实现,即多对一线程模型,其缺点是一个用户线程如果阻塞在系统调用中,则整个进程都将会阻塞。内核并没有直接对用户线程进程调度,内核的调度对象和传统进程一样,还是进程本身,内核并不知道用户线程的存在。用户线程之间的调度由在用户空间实现的线程库实现。


4、加强版用户线程

    加强版用户线程由用户线程和轻量级进程融合而成。每个线程可以对应多个轻量级进程,每个轻量级进程可以对应多个线程,即多对多模型。

    加强版用户线程的线程库继承了用户线程,线程库完全建立在用户空间中,用户线程的操作开销比较低,可以建立任意多需要的用户线程。操作系统提供了轻量级进程 LWP 作为用户线程和内核线程之间的桥梁。LWP具有内核线程支持,是内核的调度单元,并且用户线程的系统调用要通过 LWP ,因此进程中某个用户线程的阻塞不会影响整个进程的执行。用户线程库将建立的用户线程关联到 LWP 上, LWP 与用户线程的数量不一定一致。当内核调度到某个轻量级进程 LWP 上时, LWP 关联的用户线程就被执行。

wKioL1eEULuD_Pt8AAEueOTLmlo839.png

    从以上对线程的介绍可知,只有在用户线程完全由轻量级进程构成时,轻量级进程LWP才是线程。

二、线程的实现

    Linux系统中线程的实现是基于pthread线程库的,pthread在不同的linux kernel版本中是不同的,大体上以linux 2.6为分界,linux 2.6以前的linux版本的线程库采用的是linux Threads线程库,linux 2.6开始的linux版本采用NTPL线程库。

1、LinuxThreads线程的实现

    Linux 2.2开始,linux中开始使用LinuxThreads线程库。在LinuxThreads中,专门为每一个进程构造了一个管理线程,负责处理线程相关的管理工作。当进程第一次调用pthread_create创建一个线程的时候就会创建并启动管理线程,然后管理线程再来创建用户请求的线程。在LinuxThreads中,管理线程的栈和用户线程的栈是分离的,管理线程在进程堆中通过malloc分配一个THREAD_MANAGER_STACK_SIZE字节的区域作为自己的运行栈。

        Linux Threads实现基于核心轻量级进程的"一对一"线程模型,一个线程实体对应一个核心轻量级进程,线程调度交给内核,而线程之间的管理在核外函数库中实现

        LinuxThreads并不遵守POSIX线程标准

        LinuxThreads线程库的缺点:

        A、LinuxThreads线程库使用轻量级进程LWP来模拟实现线程,轻量级进程拥有独立的进程id,在进程调度、信号处理、IO等方面与普通进程一样。POSIX线程标准规定,同一进程的所有线程应该共享一个进程id和父进程id,因此LinuxThreads线程库不兼容POSIX线程标准。

    BLinuxThreads的每个线程本质上是一个轻量级线程,对内核来说是一个进程,且没有实现线程组,信号在内核中是以进程为单位分发的,因此LinuxThreads只能将一个线程(轻量级进程)挂起,而无法挂起整个进程(进程中的所有线程)

    CLinuxThreads将每个进程的线程最大数目定义为1024,实际线程数受到整个系统的总进程数限制。

    DLinuxThreads线程库中管理线程负责用户线程的清理工作,一旦管理线程死亡,用户线程只能手工清理,而且用户线程并不知道管理线程的状态,新的线程创建等请求将无人处理。

    ELinuxThreads中的线程同步是建立在信号基础上的,通过内核复杂的信号处理机制的同步方式效率较低

2、NPTL线程的实现

        LinuxThreads的兼容性问题,严重阻碍了Linux上的跨平台应用采用多线程设计,从而使得Linux上的线程应用一直保持在比较低的水平

    NPTLNative POSIX Threading Library)线程的实现模型本质上是基于轻量级进程LWP的,是对LinuxThreads线程实现模型的改进,在linux 2.6以后的版本中NPTL绑定到了GLIBC库中。基于linux 2.6的改进,NTPL实现了对POSIX的兼容。

 NPTL的优点:

    ANTPL线程库没有使用管理线程,在多处理器系统上具有可伸缩性和同步机制

    BNTPL线程库兼容POSIX线程标准

    CNPTL线程库支持ABI(应用程序二进制接口)

 

三、多线程编程优缺点

    优点:

    1无需跨进程边界

    2程序逻辑和控制方式简单

    3所有线程可以直接共享内存和变量等

    4线程方式消耗的总资源比进程方式好

    缺点:

    1每个线程与主程序共用地址空间,受限于2GB地址空间

    2线程之间的同步和加锁控制比较麻烦

    3一个线程的崩溃可能影响到整个程序的稳定性

    4到达一定的线程数程度后,即使再增加CPU也无法提高性能

    5线程能够提高的总性能有限,而且线程数量较大时,线程本身的调度开销不小

四、线程函数

1、线程创建

#include <pthread.h>

int pthread_create(pthread_t *thread, const pthread_attr_t *attr,

                          void *(*start_routine) (void *), void *arg);

    thread参数是指向线程标识符的指针

    attr参数用来设置线程属性

    start_routine参数是线程运行函数的起始地址,为函数指针

    arg参数是运行函数的参数。

        如果线程创建成功pthread_create函数就会返回0,如果创建线程失败,返回的是错误编号。
        pthread_creat函数在创建线程时由内核向新线程传递参数,如果需要传递多个参数则需要将所有参数组织在一个结构体内,再将结构体的地址作为参数
arg传给新线程。

编译时需要连接pthread线程库,即-lpthread

2、线程属性设置

#include <pthread.h>

int pthread_attr_init(pthread_attr_t *attr);

int pthread_attr_destroy(pthread_attr_t *attr);

    线程属性值不能直接设置,须使用相关函数进行操作,初始化的函数为             pthread_attr_init,pthread_attr_init函数必须在pthread_create函数之前调用。线程属性对象主要包括是否绑定、是否分离、堆栈地址、堆栈大小、优先级。默认的属性为非绑定、非分离、缺省1M的堆栈、与父进程同样级别的优先级。

typedef struct      

{      

    int    detachstate;   //线程的分离状态    

    int    schedpolicy;  //线程调度策略     

    struct sched_param  schedparam;   // 线程的调度参数     

    int    inheritsched;  //线程的继承性      

    int    scope;       //线程的作用域      

    size_t guardsize;   //线程栈末尾的警戒缓冲区大小    

    int    stackaddr_set;      

    void * stackaddr;   //线程栈的位置    

    size_t stacksize;    //线程栈的大小      

}pthread_attr_t;    

 

A、线程的绑定属性

    线程的绑定和轻进程(LWP:Light Weight Process)有关。轻进程可以理解为内核线程,位于用户层和系统层之间。系统对线程资源的分配、对线程的控制是通过轻进程来实现的,一个轻进程可以控制一个或多个线程。默认状况下,启动多少轻进程、哪些轻进程来控制哪些线程是由系统来控制的,此时线程是非绑定的。线程的绑定属性就是线程固定的绑定在某一个轻进程之上。由于CPU时间片的调度是面向轻进程的,绑定线程到某一个轻进程可以保证在需要的时候总有一个轻进程可用,同时通过设置线程所绑定的轻进程的优先级和调度级可以使得绑定的线程具有较高的响应速度,满足诸如实时反应之类的要求。

#include <pthread.h>

int pthread_attr_setscope(pthread_attr_t *attr, int scope);

    attr是线程属性对象的指针

    scope是绑定类型,拥有两个取值PTHREAD_SCOPE_SYSTEM(绑定的)和PTHREAD_SCOPE_PROCESS(非绑定的)

        Linux的线程永远都是绑定的,所以PTHREAD_SCOPE_PROCESS在Linux中不管用,而且会返回ENOTSUP错误。PTHREAD_SCOPE_PROCESS出现是为了实现NTPLPOSIX线程标准兼容,NTPL如果运用在其他系统可能会使用到。


B、线程的分离属性

    分离属性就是让线程在创建之前就决定线程应该是分离的。如果设置了这个属性,就没有必要调用pthread_joinpthread_detach来回收线程资源。

#include <pthread.h>

int pthread_attr_setdetachstate(pthread_attr_t *attr, int detachstate);

    detachstatePTHREAD_CREATE_DETACHED(分离的)和PTHREAD_CREATE_JOINABLE(可合并的,默认属性)。


C、线程的调度属性

    线程的调度属性有三个,分别是:算法、优先级和继承权。

        Linux提供的线程调度算法有三个:轮询、先进先出和其它。其中轮询和先进先出调度算法是POSIX标准所规定,而其他则代表采用Linux自己认为更合适的调度算法,所以默认的调度算法就是其它。轮询和先进先出调度算法都属于实时调度算法。轮询指的是时间片轮转,当线程的时间片用完,系统将重新分配时间片,并将它放置在就绪队列尾部,保证具有相同优先级的轮询任务获得公平的CPU占用时间;先进先出就是先到先服务,一旦线程占用了CPU则 一直运行,直到有更高优先级的线程出现或自己放弃。

        设置线程调度算法的函数是pthread_attr_setschedpolicy

#include <pthread.h>

int pthread_attr_setschedpolicy(pthread_attr_t *attr, int policy);

    policy参数有三个取值SCHED_RR(轮询)、SCHED_FIFO(先进先出)和SCHED_OTHER(其它)。

        Linux的线程优先级与进程的优先级不一样,Linux的线程优先级是从199的数值,数值越大代表优先级越高,但只有采用SHCED_RRSCHED_FIFO调度算法时,优先级才有效。对于采用SCHED_OTHER调度算法的线程,其优先级恒为0

    设置线程优先级的接口是pthread_attr_setschedparam

 #include <pthread.h>

int pthread_attr_setschedparam(pthread_attr_t *attr,

                                    const struct sched_param *param);

 struct sched_param {

               int sched_priority;     /* Scheduling priority */

           };

    线程优先级不是随便就能设置的。首先,进程必须是以root账号运行的;其次,还需要放弃线程的继承权。什么是继承权呢?就是当创建新的线程时,新线程要继承父线程(创建者线程)的调度属性。如果不希望新线程继承父线程的调度属性,就要放弃继承权。

#include <pthread.h>

int pthread_attr_setinheritsched(pthread_attr_t *attr,int inheritsched);

inheritsched参数有两个取值PTHREAD_INHERIT_SCHED(拥有继承权)和PTHREAD_EXPLICIT_SCHED(放弃继承权)。新线程在默认情况下是拥有继承权。


D、线程的堆栈大小

    线程的主函数与程序的主函数main一样可以拥有局部变量。虽然同一个进程的线程之间是共享内存空间的,但是它的局部变量确并不共享。原因是局部变量存储在堆栈中,而不同的线程拥有不同的堆栈。Linux系统为每个线程默认分配了8MB的堆栈空间,如果觉得这个空间不够用,可以通过修改线程的堆栈大小属性进行扩容。

#include <pthread.h>

int pthread_attr_setstacksize(pthread_attr_t *attr, size_t stacksize);

    stacksize参数是堆栈大小,以字节为单位。线程堆栈不能小于16KB,而且尽量按4KB(32位系统)2MB64位系统)的整数倍分配,内存页面大小的整数倍。修改线程堆栈大小是有风险的。


E、线程的满栈警戒区

    线程是有堆栈的,而且还有大小限制,那么就一定会出现将堆栈用满的情况。线程的堆栈用满是非常危险的事情,这会导致对内核空间的破坏,一旦被有心人 士所利用,后果也不堪设想。为了防治这类事情的发生,Linux为线程堆栈设置了一个满栈警戒区。满栈警戒区一般就是一个页面,属于线程堆栈的一个扩展区 域。一旦有代码访问了这个区域,就会发出SIGSEGV信号进行通知。

    虽然满栈警戒区可以起到安全作用,但是会白白浪费掉内存空间,对于内存紧张的系统会使系统变得很慢。所有就有了关闭这个警戒区的需求。同时,如果我们修改了线程堆栈的大小,那么系统会认为我们会自己管理堆栈,也会将警戒区取消掉,如果有需要就要开启它。

 #include <pthread.h>

 int pthread_attr_setguardsize(pthread_attr_t *attr, size_t guardsize);

    guardsize参数是警戒区大小,以字节为单位。与设置线程堆栈大小属性一样,尽量按照4KB或2MB的整数倍来分配。当设置警戒区大小为0时,就关闭了这个警戒区。

    虽然栈满警戒区需要浪费掉一点内存,但是能够极大的提高安全性。一旦修改了线程堆栈的大小,一定要记得同时设置这个警戒区。


3、线程的数据

    线程的最大优点之一是数据的共享性,进程中的所有线程共享进程中的数据段,可以方便的获得、修改数据。多个不同的线程访问相同的变量。许多函数是不可重入的,即同时不能运行一个函数的多个拷贝(除非使用不同的数据段)。在函数中声明的静态变量常常带来问题,函数的返回值也会有问题。因为如果返回的是函数内部静态声明的空间的地址,则在一个线程调用 该函数得到地址后使用该地址指向的数据时,别的线程可能调用此函数并修改了这一段数据。在进程中共享的变量必须用关键字volatile来定义,这是为了 防止编译器在优化时(如gcc中使用-OX参数)改变它们的使用方式。为了保护变量,我们必须使用信号量、互斥等方法来保证我们对变量的正确使用。
    在单线程的程序里,有两种基本的数据:全局变量和局部变量。在多线程程序里,还有第三种数据类型:线程数据(TSD: Thread-Specific Data),在线程内部,各个函数可以象使用全局变量一样调用线程数据,但线程数据对线程外部的线程是不可见的。


4、线程的终止

    线程的退出方式有三种:
    A、线程体函数从启动线程中返回,return
    B、线程被另一个线程终止。

    C线程自行调用pthread_exit退出。pthread_exit可以在退出的时候传递一些信息,传递的信息可以用pthread_join函数获得。

exit是进程退出,如果在线程函数中调用exit,线程的进程也就会退出。会导致线程所在进程的其他线程退出。

取消线程:

#include <pthread.h>

int pthread_cancel(pthread_t thread);

    在别的线程中要终止另一个线程

#include <pthread.h>

int pthread_setcancelstate(int state, int *oldstate);

    被取消的线程可以设置自己的取消状态

    state参数有:PTHREAD_CANCEL_ENABLE(缺省)和PTHREAD_CANCEL_DISABLE

    线程接收到CANCEL信号的缺省处理(即pthread_create()创建线程的缺省状态)是继续运行至取消点,也就是说设置一个CANCELED状态,线程继续运行,只有运行至Cancelation-point的时候才会退出。


5、线程的合并和分离

    线程的合并:

    线程的合并是一种主动回收线程资源的方案。当一个进程或线程调用了针对其它线程的pthread_join函数,就是线程合并。pthread_join函数会阻塞调用进程或线程,直到被合并的线程结束为止。当被合并线程结束,pthread_join函数就会回收线程的资源,并将线程的返回值返回给合并者。

#include <pthread.h>

int pthread_join(pthread_t thread, void **retval);

    线程的分离:

    线程分离也是一种线程资源回收机制,使用pthread_detach函数实现线程分离。线程分离是将线程资源的回收工作交由系统自动来完成,就是说当被分离的线程结束之后,系统会自动回收线程的资源。因为线程分离是启动系统的自动回收机制,那么程序也就无法获得被分离线程的返回值。

#include <pthread.h>

int pthread_detach(pthread_t thread);

 

    线程合并和线程分离都是用于回收线程资源的,可以根据不同的业务场景酌情使用。


6、线程本地存储

    进程内线程之间可以共享内存地址空间,线程之间的数据交换可以非常快捷,但是多个线程访问共享数据,需要昂贵的同步开销。C程序库中的errno是一个全局变量,会保存最后一个系统调用的错误代码。在单线程环境并不会出现什么问题。在多线程环境,所有线程都会有可能修改errno,很难确定errno代表的到底是哪个系统调用的错误代码,造成非线程安全(Non Thread-Safe)。

为了解决非线程安全问题,引入了线程本地存储,即 Thread Local Storage,简称TLS。利用TLS,errno所反映的就是本线程内最后一个系统调用的错误代码了,也就是线程安全的。

int pthread_key_create(pthread_key_t *key, void (*destructor)(void*)); 

    创建一个线程本地存储区

    key参数返回线程本地存储区的句柄,需要使用一个全局变量保存,以便所有线程都能访问到。

    destructor参数是线程本地数据的回收函数指针

int pthread_key_delete(pthread_key_t key); 

         用于回收线程本地存储区。key参数就要回收的存储区的句柄。

void* pthread_getspecific(pthread_key_t key);  

        获取线程本地存储区的数据

int pthread_setspecific(pthread_key_t key, const void *value);  

        设置线程本地存储区的数据。