linux多线程概述和基本管理

线程概念

什么是线程?

线程是参与系统调度的最小单位。它被包含在进程之中,是进程中的实际运行单位。一个线程指的是进程中一个单一顺序的控制流(或者说是执行路线、执行流),一个进程中可以创建多个线程,多个线程实现并发运行,每个线程执行不同的任务。譬如某应用程序设计了两个需要并发运行的任务 task1 和 task2,可将两个不同的任务分别放置在两个线程中。

线程是如何创建起来的?

当一个程序启动时,就有一个进程被操作系统(OS)创建,与此同时一个线程也立刻运行,该线程通常叫做程序的主线程(Main Thread),因为它是程序一开始时就运行的线程。应用程序都是以 main()做为入口开始运行的,所以 main()函数就是主线程的入口函数,main()函数所执行的任务就是主线程需要执行的任务。

所以由此可知,任何一个进程都包含一个主线程,只有主线程的进程称为单线程进程,譬如前面章节内容中所编写的所有应用程序都是单线程程序,它们只有主线程;既然有单线程进程,那自然就存在多线程进程,所谓多线程指的是除了主线程以外,还包含其它的线程,其它线程通常由主线程来创建(调用pthread_create 创建一个新的线程),那么创建的新线程就是主线程的子线程。

主线程的重要性体现在两方面:

  • ⚫ 其它新的线程(也就是子线程)是由主线程创建的;
  • ⚫ 主线程通常会在最后结束运行,执行各种清理工作,譬如回收各个子线程。

线程的特点?

线程是程序最基本的运行单位,而进程不能运行,真正运行的是进程中的线程。当启动应用程序后,系统就创建了一个进程,可以认为进程仅仅是一个容器,它包含了线程运行所需的数据结构、环境变量等信息。

同一进程中的多个线程将共享该进程中的全部系统资源,如虚拟地址空间,文件描述符和信号处理等等。但同一进程中的多个线程有各自的调用栈(call stack,我们称为线程栈),自己的寄存器环境(register context)、自己的线程本地存储(thread-local storage)。

在多线程应用程序中,通常一个进程中包括了多个线程,每个线程都可以参与系统调度、被 CPU 执行,线程具有以下一些特点:

  • ⚫ 线程不单独存在、而是包含在进程中;
  • ⚫ 线程是参与系统调度的基本单位;
  • ⚫ 可并发执行。同一进程的多个线程之间可并发执行,在宏观上实现同时运行的效果;
  • ⚫ 共享进程资源。同一进程中的各个线程,可以共享该进程所拥有的资源,这首先表现在:所有线程都具有相同的地址空间(进程的地址空间),这意味着,线程可以访问该地址空间的每一个虚地址;此外,还可以访问进程所拥有的已打开文件、定时器、信号量等等。

线程与进程?

进程创建多个子进程可以实现并发处理多任务(本质上便是多个单线程进程),多线程同样也可以实现(一个多线程进程)并发处理多任务的需求,那我们究竟选择哪种处理方式呢?首先我们就需要来分析下多进程和多线程两种编程模型的优势和劣势。

多进程编程的劣势:

  • ⚫ 进程间切换开销大。多个进程同时运行(指宏观上同时运行,无特别说明,均指宏观上),微观上依然是轮流切换运行,进程间切换开销远大于同一进程的多个线程间切换的开销,通常对于一些中小型应用程序来说不划算。
  • ⚫ 进程间通信较为麻烦。每个进程都在各自的地址空间中、相互独立、隔离,处在于不同的地址空间中,因此相互通信较为麻烦。

解决方案便是使用多线程编程,多线程能够弥补上面的问题:

  • ⚫ 同一进程的多个线程间切换开销比较小。
  • ⚫ 同一进程的多个线程间通信容易。它们共享了进程的地址空间,所以它们都是在同一个地址空间中,通信容易。
  • ⚫ 线程创建的速度远大于进程创建的速度。
  • ⚫ 多线程在多核处理器上更有优势!

综上所述,多线程编程相比于多进程编程的优势是比较明显的,在实际的应用当中多线程远比多进程应用更为广泛。那既然如此,为何还存在多进程编程模型呢?难道多线程编程就不存在缺点吗?当然不是,多线程也有它的缺点、劣势,譬如多线程编程难度高,对程序员的编程功底要求比较高,因为在多线程环境下需要考虑很多的问题,例如线程安全问题、信号处理的问题等,编写与调试一个多线程程序比单线程程序困难得多。

当然除此之外,还有一些其它的缺点,这里就不再一一列举了。多进程编程通常会用在一些大型应用程序项目中,譬如网络服务器应用程序,在中小型应用程序中用的比较少。

注意Linux中的相关概念和RTOS中的相关概念的区别。

Linux中会有多个进程,相对复杂,RTOS中一般只有一个进程,也就是我们的应用,然后里面有各个子任务,对应的是Linux中的线程的概念。

有哪些不一样呢?比如RTOS中消息队列就是在各线程任务之间传递消息,而Linux中主要是用在进程间通信;又比如RTOS中的信号量分为好几种,比如二值信号量、计数信号量、互斥信号量等等,都用在任务间的同步,而Linux中,这些技术有的是用于线程的,有的是用于进程的;再比如RTOS中有临界区保护这一技术手段,不过Linux中一般就是通过互斥锁信号量等等实现。

总之要明确的是,RTOS中一般只有线程的概念,也就是各子任务,所以各技术,都是用在任务之间;但是Linux中,既有线程又有进程,不同的技术有不同的应用场景,所以要加以区分。 另外,不同的操作系统,可能略有差别,实际使用时,需要进一步了解,不过用法都大差不差,重要的是,别以为都是统一的用法,然后产生疑惑,还不知道是咋回事。

线程 ID

就像每个进程都有一个进程 ID 一样,每个线程也有其对应的标识,称为线程 ID。进程 ID 在整个系统中是唯一的,但线程 ID 不同,线程 ID 只有在它所属的进程上下文中才有意义。

进程 ID 使用 pid_t 数据类型来表示,它是一个非负整数。而线程 ID 使用 pthread_t 数据类型来表示,一个线程可通过库函数 pthread_self()来获取自己的线程 ID,其函数原型如下所示:

#include <pthread.h>
pthread_t pthread_self(void);

使用该函数需要包含头文件<pthread.h>。

该函数调用总是成功,返回当前线程的线程 ID。

可以使用 pthread_equal()函数来检查两个线程 ID 是否相等,其函数原型如下所示:

#include <pthread.h>
int pthread_equal(pthread_t t1, pthread_t t2);

如果两个线程 ID t1 和 t2 相等,则 pthread_equal()返回一个非零值;否则返回 0。在 Linux 系统中,使用无符号长整型(unsigned long int)来表示 pthread_t 数据类型,但是在其它系统当中,则不一定是无符号长整型,所以我们必须将 pthread_t 作为一种不透明的数据类型加以对待,所以 pthread_equal()函数用于比较两个线程 ID 是否相等是有用的。

Linux 系统下线程 ID 数值非常大,看起来像是一个指针,比如:

线程 ID 在应用程序中非常有用,原因如下:

⚫ 很多线程相关函数,譬如后面将要学习的 pthread_cancel()、pthread_detach()、pthread_join()等,它们都是利用线程 ID 来标识要操作的目标线程;

⚫ 在一些应用程序中,以特定线程的线程 ID 作为动态数据结构的标签,这某些应用场合颇为有用,既可以用来标识整个数据结构的创建者或属主线程,又可以确定随后对该数据结构执行操作的具体线程。 

Posix在线文档:

The Single UNIX Specification, Version 2 (opengroup.org)

Pthreads 概述

历史上,硬件销售商实现了私有版本的多线程库。这些实现在本质上各自不同,使得程序员难于开发可移植的应用程序。 为了使用线程所提供的强大优点,需要一个标准的程序接口。对于UNIX系统,IEEE POSIX 1003.1c(1995)标准制订了这一标准接口。依赖于该标准的实现就称为POSIX threads 或者Pthreads。现在多数硬件销售商也提供Pthreads,附加于私有的API。 Pthreads 被定义为一些C语言类型和函数调用,用pthread.h头(包含)文件和线程库实现。

所以说,Pthreads,其实就是POSIX threads,也就是符合posix标准的线程操作接口。

创建线程

启动程序时,创建的进程只是一个单线程的进程,称之为初始线程或主线程,本小节我们讨论如何创建一个新的线程。

主线程可以使用库函数 pthread_create()负责创建一个新的线程,创建出来的新线程被称为主线程的子线程,其函数原型如下所示:

#include <pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);

使用该函数需要包含头文件<pthread.h>。

函数参数和返回值含义如下:

thread:

pthread_t 类型指针,当 pthread_create()成功返回时,新创建的线程的线程 ID 会保存在参数 thread所指向的内存中,后续的线程相关函数会使用该标识来引用此线程。

attr:

pthread_attr_t 类型指针,指向 pthread_attr_t 类型的缓冲区,pthread_attr_t 数据类型定义了线程的各种属性,关于线程属性将会在 11.8 小节介绍。如果将参数 attr 设置为 NULL,那么表示将线程的所有属性设置为默认值,以此创建新线程。

start_routine:

参数 start_routine 是一个函数指针,指向一个函数,新创建的线程从 start_routine()函数开始运行,该函数返回值类型为void *,并且该函数的参数只有一个void *,其实这个参数就是pthread_create()函数的第四个参数 arg。如果需要向 start_routine()传递的参数有一个以上,那么需要把这些参数放到一个结构体中,然后把这个结构体对象的地址作为 arg 参数传入。

arg:

传递给 start_routine()函数的参数。一般情况下,需要将 arg 指向一个全局或堆变量,意思就是说在线程的生命周期中,该 arg 指向的对象必须存在,否则如果线程中访问了该对象将会出现错误。当然也可将参数 arg 设置为 NULL,表示不需要传入参数给 start_routine()函数。

返回值:

成功返回 0;失败时将返回一个错误号,并且参数 thread 指向的内容是不确定的。

注意 pthread_create()在调用失败时通常会返回错误码,它并不像其它库函数或系统调用一样设置 errno,每个线程都提供了全局变量 errno 的副本,这只是为了与使用 errno 到的函数进行兼容,在线程中,从函数中返回错误码更为清晰整洁,不需要依赖那些随着函数执行不断变化的全局变量,这样可以把错误的范围限制在引起出错的函数中。

线程创建成功,新线程就会加入到系统调度队列中,获取到 CPU 之后就会立马从 start_routine()函数开始运行该线程的任务;调用 pthread_create()函数后,通常我们无法确定系统接着会调度哪一个线程来使用CPU 资源,先调度主线程还是新创建的线程呢(而在多核 CPU 或多 CPU 系统中,多核线程可能会在不同的核心上同时执行)?如果程序对执行顺序有强制要求,那么就必须采用一些同步技术来实现。

注意,线程创建时,并没有指定线程的名称,也就是我们执行ps命令时看到的线程的名字。为了方便查看和调试,通常我们都会给线程取个名字,如果我们需要的话,可以单独设置线程的名称。

设置线程名可以有两种方式:

设置线程名的两种方式:prctl和pthread_setname_np_prctl函数-CSDN博客

方式一:prctl函数,参考:Linux进程基础-CSDN博客

方式二:pthread_setname_np,下文中介绍

设置/获取线程的名称

#define _GNU_SOURCE /* See feature_test_macros(7) */
#include <pthread.h>
int pthread_setname_np(pthread_t thread, const char *name);
int pthread_getname_np(pthread_t thread, char *name, size_t len);

第一个参数:需要设置/获取名称的线程;

第二个参数:要设置/获取名称的buffer,通常是一个名称字符串,并且要求name的buffer空间不能超过16个字节。

注意,线程的名称和线程的ID是两个东西,不要混为一谈。

终止线程

可以通过如下方式终止线程的运行:

⚫ 线程的 start 函数执行 return 语句并返回指定值,返回值就是线程的退出码;

⚫ 线程调用 pthread_exit()函数;

⚫ 调用 pthread_cancel()取消线程

!!!如果进程中的任意线程调用 exit()、_exit()或者_Exit(),那么将会导致整个进程终止,这里需要注意!

pthread_exit()函数将终止调用它的线程,其函数原型如下所示:

#include <pthread.h>
void pthread_exit(void *retval);

使用该函数需要包含头文件<pthread.h>。

参数 retval 的数据类型为 void *,指定了线程的返回值、也就是线程的退出码,该返回值可由另一个线程通过调用 pthread_join()来获取;同理,如果线程是在 start 函数中执行 return 语句终止,那么 return 的返回值也是可以通过 pthread_join()来获取的。

参数 retval 所指向的内容不应分配于线程栈中,因为线程终止后,将无法确定线程栈的内容是否有效;出于同样的理由,也不应在线程栈中分配线程 start 函数的返回值。

调用 pthread_exit()相当于在线程的 start 函数中执行 return 语句,不同之处在于,可在线程 start 函数所调用的任意函数中调用 pthread_exit()来终止线程。

!!!如果主线程调用了 pthread_exit(),那么主线程也会终止, 但其它线程依然正常运行,直到进程中的所有线程终止才会使得进程终止。

取消线程

注意,pthread_exit是自己退出;pthread_cancel是别人要求某个线程退出。

具体参考:Linux线程(3)——pthread_cancel()取消一个线程_pthread cancel-CSDN博客

在通常情况下,进程中的多个线程会并发执行,每个线程各司其职,直到线程的任务完成之后,该线程中会调用 pthread_exit()退出,或在线程 start 函数执行 return 语句退出。

有时候,在程序设计需求当中,需要向一个线程发送一个请求,要求它立刻退出,我们把这种操作称为取消线程,也就是向指定的线程发送一个请求,要求其立刻终止、退出。譬如,一组线程正在执行一个运算, 一旦某个线程检测到错误发生,需要其它线程退出,取消线程这项功能就派上用场了。下面就来讨论 Linux 系统下的线程取消机制。

取消一个线程

通过调用 pthread_cancel()库函数向一个指定的线程发送取消请求,其函数原型如下所示:

#include <pthread.h>
int pthread_cancel(pthread_t thread);

参数 thread 指定需要取消的目标线程;成功返回 0,失败将返回错误码。

发出取消请求之后,函数 pthread_cancel()立即返回,不会等待目标线程的退出。默认情况下,目标线程也会立刻退出,其行为表现为如同调用了参数为 PTHREAD_CANCELED(其实就是(void *)-1)的pthread_exit()函数,但是,线程可以设置自己不被取消或者控制如何被取消,所以 pthread_cancel()并不会等待线程终止,仅仅只是提出请求。

回收线程

在父、子进程当中,父进程可通过 wait()函数(或其变体 waitpid())阻塞等待子进程退出并获取其终止状态,回收子进程资源;而在线程当中,也需要如此,通过调用 pthread_join()函数来阻塞等待线程的终止,并获取线程的退出码,回收线程资源;pthread_join()函数原型如下所示:

#include <pthread.h>
int pthread_join(pthread_t thread, void **retval);

使用该函数需要包含头文件<pthread.h>。

函数参数和返回值含义如下:

thread:pthread_join()等待指定线程的终止,通过参数 thread(线程 ID)指定需要等待的线程;

retval:如果参数 retval 不为 NULL,则 pthread_join()将目标线程的退出状态(即目标线程通过pthread_exit()退出时指定的返回值或者在线程 start 函数中执行 return 语句对应的返回值)复制到*retval 所指向的内存区域;如果目标线程被 pthread_cancel()取消,则将 PTHREAD_CANCELED 放在*retval 中。如果对目标线程的终止状态不感兴趣,则可将参数 retval 设置为 NULL。

返回值:成功返回 0;失败将返回错误码。

调用 pthread_join()函数将会以阻塞的形式等待指定的线程终止,如果该线程已经终止,则 pthread_join()立刻返回。如果多个线程同时尝试调用 pthread_join()等待指定线程的终止,那么结果将是不确定的。

若线程并未分离(detached,将在 11.6.1 小节介绍),则必须使用 pthread_join()来等待线程终止,回收线程资源;如果线程终止后,其它线程没有调用 pthread_join()函数来回收该线程,那么该线程将变成僵尸线程,与僵尸进程的概念相类似;同样,僵尸线程除了浪费系统资源外,若僵尸线程积累过多,那么会导致应用程序无法创建新的线程。

当然,如果进程中存在着僵尸线程并未得到回收,当进程终止之后,进程会被其父进程回收,所以僵尸线程同样也会被回收。

所以,通过上面的介绍可知,pthread_join()执行的功能类似于针对进程的 waitpid()调用,不过二者之间存在一些显著差别:

⚫ 线程之间关系是对等的。进程中的任意线程均可调用 pthread_join()函数来等待另一个线程的终止。譬如,如果线程 A 创建了线程 B,线程 B 再创建线程 C,那么线程 A 可以调用 pthread_join()等待线程 C 的终止,线程 C 也可以调用 pthread_join()等待线程 A 的终止;这与进程间层次关系不同,父进程如果使用 fork()创建了子进程,那么它也是唯一能够对子进程调用 wait()的进程,线程之间不存在这样的关系。

⚫ 不能以非阻塞的方式调用 pthread_join()。对于进程,调用 waitpid()既可以实现阻塞方式等待、也可以实现非阻塞方式等待。

分离线程

默认情况下,当线程终止时,其它线程可以通过调用 pthread_join()获取其返回状态、回收线程资源,有时,程序员并不关心线程的返回状态,只是希望系统在线程终止时能够自动回收线程资源并将其移除。在这种情况下,可以调用 pthread_detach()将指定线程进行分离,也就是分离线程,pthread_detach()函数原型如下所示:

#include <pthread.h>
int pthread_detach(pthread_t thread);

使用该函数需要包含头文件<pthread.h>,参数 thread 指定需要分离的线程,函数 pthread_detach()调用成功将返回 0;失败将返回一个错误码。

一个线程既可以将另一个线程分离,同时也可以将自己分离,譬如:

pthread_detach(pthread_self());

一旦线程处于分离状态,就不能再使用 pthread_join()来获取其终止状态,此过程是不可逆的,一旦处于分离状态之后便不能再恢复到之前的状态。处于分离状态的线程,当其终止后,能够自动回收线程资源。

示例 

#include <stdio.h>
#include <pthread.h>
#include <stdlib.h>
#include <unistd.h>

void *thread_fun1(void *param)
{
    while(1)
    {
        printf("I am thread-1\n");
        sleep(1);
    }
    
    return NULL;
}

void *thread_fun2(void *param)
{
    while(1)
    {
        printf("you are thread-2\n");
        sleep(1);
    }
    
    return NULL;
}

int main(int argc, char *argv[])
{
    pthread_t tid1, tid2;
    int rc;

    rc = pthread_create(&tid1, NULL, thread_fun1, NULL);
    if (rc < 0){ 
         printf("ERROR; return code from pthread_create() is %d\n", rc); 
         exit(-1); 
    } 

    rc = pthread_create(&tid2, NULL, thread_fun2, NULL);
    if (rc < 0){ 
         printf("ERROR; return code from pthread_create() is %d\n", rc); 
         exit(-1); 
    }

    pthread_exit(NULL);//主线程退出了
}

线程属性

如前所述,调用 pthread_create()创建线程,可对新建线程的各种属性进行设置。在 Linux 下,使用pthread_attr_t 数据类型定义线程的所有属性,本书并不打算详细讨论这些属性,以介绍为主,简单地了解下线程属性。

调用 pthread_create()创建线程时,参数 attr 设置为 NULL,表示使用属性的默认值创建线程。如果不使用默认值,参数 attr 必须要指向一个 pthread_attr_t 对象,而不能使用 NULL。当定义 pthread_attr_t 对象之后 ,需要 使用 pthread_attr_init()函 数 对 该对象进 行初始 化操作 ,当对象 不再使 用时, 需要使用pthread_attr_destroy()函数将其销毁,函数原型如下所示:

#include <pthread.h>
int pthread_attr_init(pthread_attr_t *attr);
int pthread_attr_destroy(pthread_attr_t *attr);

使用这些函数需要包含头文件<pthread.h>,参数 attr 指向一个 pthread_attr_t 对象,即需要进行初始化的线程属性对象。在调用成功时返回 0,失败将返回一个非 0 值的错误码。

pthread_attr_t 数据结构中包含的属性比较多,本小节并不会一一点出,可能比较关注属性包括:线程栈的位置和大小、线程调度策略和优先级,以及线程的分离状态属性等。Linux 为 pthread_attr_t 对象的每种属性提供了设置属性的接口以及获取属性的接口。

线程栈属性

参考:【多线程编程学习笔记13】线程属性有哪些,如何自定义线程属性?-CSDN博客

每个线程都有自己的栈空间,pthread_attr_t 数据结构中定义了栈的起始地址以及栈大小,调用函数pthread_attr_getstack()可以获取这些信息,函数 pthread_attr_setstack()对栈起始地址和栈大小进行设置,其函 数原型如下所示:

#include <pthread.h>
int pthread_attr_setstack(pthread_attr_t *attr, void *stackaddr, size_t stacksize);
int pthread_attr_getstack(const pthread_attr_t *attr, void **stackaddr, size_t *stacksize);

使用这些函数需要包含头文件<pthread.h>,函数 pthread_attr_getstack(),参数和返回值含义如下:

attr:参数 attr 指向线程属性对象。

stackaddr:调用 pthread_attr_getstack()可获取栈起始地址,并将起始地址信息保存在*stackaddr 中;

stacksize:调用 pthread_attr_getstack()可获取栈大小,并将栈大小信息保存在参数 stacksize 所指向的内存中;

返回值:成功返回 0,失败将返回一个非 0 值的错误码。

函数 pthread_attr_setstack(),参数和返回值含义如下:

attr:参数 attr 指向线程属性对象。

stackaddr:设置栈起始地址为指定值。

stacksize:设置栈大小为指定值;

返回值:成功返回 0,失败将返回一个非 0 值的错误码。

如果想单独获取或设置栈大小、栈起始地址,可以使用下面这些函数:

#include <pthread.h>
int pthread_attr_setstacksize(pthread_attr_t *attr, size_t stacksize);
int pthread_attr_getstacksize(const pthread_attr_t *attr, size_t *stacksize);
int pthread_attr_setstackaddr(pthread_attr_t *attr, void *stackaddr);
int pthread_attr_getstackaddr(const pthread_attr_t *attr, void **stackaddr);

线程优先级

<pthread.h> 头文件中提供了如下两个函数,用于获取和修改线程的优先级属性值:

int pthread_attr_getschedparam(const pthread_attr_t *attr, struct sched_param *param);
int pthread_attr_setschedparam(pthread_attr_t *attr, const struct sched_param *param);

其中,param 参数用于接收或者修改优先级,它是 sched_param 结构体类型的变量,定义在 <sched.h> 头文件中,内部仅有一个 sched_priority 整形变量,用于表示线程的优先级。函数执行成功时返回数字 0,反之返回非零数。

当需要修改线程的优先级时,我们只需创建一个 sched_param 类型的变量并为其内部的 sched_priority 成员赋值,然后将其传递给 pthrerd_attr_setschedparam() 函数。

示例如下:

pthread_attr_t attr;
struct sched_param paramValue;
paramValue.sched_priority = 100;
pthrerd_attr_setschedparam(&attr, &paramValue);

线程安全

当我们编写的程序是一个多线程应用程序时,就不得不考虑到线程安全的问题,确保我们编写的程序是一个线程安全(thread-safe)的多线程应用程序,什么是线程安全以及如何保证线程安全?带着这些问题,本小节将讨论线程安全相关的话题。

多线程编程中,经常遇到的就是线程安全问题,或者说要考虑线程同步的问题。

线程同步这个概念其实挺抽象的。关于线程同步,是指各线程执行时需要按照一定的先后顺序,而不是各线程自己想干嘛就干嘛。

本人之前对线程同步,以及主要的同步技术如互斥锁、信号量、条件变量等没怎么搞透彻,这里再想一想看看。

考虑以下三个场景:

  • 场景1:多个线程,改变了同一个全局变量;
  • 场景2:多个线程,都调用printf来打印输出;
  • 场景3:多个线程,其中A线程必须等B线程执行结束才能执行。

针对场景1,可以使用互斥锁,保证同一时间只有一个线程来操作全局变量,本质是什么呢?本质是因为修改变量的过程并不是原子操作,而是会被打断的,所以,需要在修改变量期间,保证不会被其他线程打断;

针对场景2,我们需要的其实也是printf这个过程不会被打断,要不然就是这个线程打印一会儿,那个线程打印一会儿,输出的内容都串在一起了,这种情况下怎么处理呢?在freertos中,不想某段操作被其他任务打断,可以使用临界区函数来实现,那么Linux中呢?其实可以用信号量,想一想,信号量其实主要就是统计某个资源的使用情况,printf其实就是一个输出的资源,我们可以用信号量来实现,每次只有一个线程可以获取该资源;

针对这两个场景再想想,printf是个资源,其实一个全局变量也是个资源,是不是也可以用信号量呢?另外,printf要想不被打断,是不是也可以使用互斥锁呢?理论上都是可以互换的。

二者本质上都是为了实现原子操作。

再来看看第三个场景,各线程有先后执行顺序,就可以使用条件变量,这一点,相对前面两个场景,没那么容易搞混。

而这三个技术,互斥锁、信号量以及条件变量,就是多线程编程中,实现线程同步的主要手段。再看看线程同步这个词,意思其实就是我这个线程操作资源时,你其他线程得等着,这就是同步的含义。使用线程同步技术,从而保证线程安全。

这几个技术的本质应该是类似的,只是侧重点不一样,需要在实际开发中慢慢体会。

线程栈

进程中创建的每个线程都有自己的栈地址空间,将其称为线程栈。譬如主线程调用 pthread_create()创建了一个新的线程,那么这个新的线程有它自己独立的栈地址空间、而主线程也有它自己独立的栈地址空间。

在创建一个新的线程时,可以配置线程栈的大小以及起始地址,当然在大部分情况下,保持默认即可!

既然每个线程都有自己的栈地址空间,那么每个线程运行过程中所定义的自动变量(局部变量)都是分配在自己的线程栈中的,它们不会相互干扰。在示例代码 11.10.1 中,主线程创建了 5 个新的线程,这 5 个线程使用同一个 start 函数 new_thread,该函数中定义了局部变量 number 和 tid 以及 arg 参数,意味着这 5个线程的线程栈中都各自为这些变量分配了内存空间,任何一个线程修改了 number 或 tid 都不会影响其它线程。

可重入函数

要解释可重入(Reentrant)函数为何物,首先需要区分单线程程序和多线程程序。本章开头部分已向各位读者进行了详细介绍,单线程程序只有一条执行流(一个线程就是一条执行流),贯穿程序始终;而对于多线程程序而言,同一进程却存在多条独立、并发的执行流。

进程中执行流的数量除了与线程有关之外,与信号处理也有关联。因为信号是异步的,进程可能会在其运行过程中的任何时间点收到信号,进而跳转、执行信号处理函数,从而在一个单线程进程(包含信号处理)中形成了两条(即主程序和信号处理函数)独立的执行流。

接下来再来介绍什么是可重入函数,如果一个函数被同一进程的多个不同的执行流并发调用,每次函数调用总是能产生正确的结果(或者叫产生预期的结果),把这样的函数就称为可重入函数。

可重入函数的分类

笔者认为可重入函数可以分为两类:

绝对的可重入函数:所谓绝对,指的是该函数不管如何调用,都刚断言它是可重入的,都能得到预期的结果。

带条件的可重入函数:指的是在满足某个/某些条件的情况下,可以断言该函数是可重入的,不管怎么调用都能得到预期的结果。

绝对可重入函数

笔者查阅过很多的书籍以及网络文章,并未发现有提出过这种分类,所以这完全是笔者个人对此的一个理解,首先来看一下绝对可重入函数的一个例子,如下所示:

函数 func()就是一个标准的绝对可重入函数:

static int func(int a)

{

int local;

int j;

for (local = 0, j = 0; j < 5; j++) {

local += a * a;

a += 2;

}

return local;

}

该函数内操作的变量均是函数内部定义的自动变量(局部变量),每次调用函数,都会在栈内存空间为局部变量分配内存,当函数调用结束返回时、再由系统回收这些变量占用的栈内存,所以局部变量生命周期只限于函数执行期间。

除此之外,该函数的参数和返回值均是值类型、而并非是引用类型(就是指针)。

如果多条执行流同时调用函数 func(),那必然会在栈空间中存在多份局部变量,每条执行流操作各自的局部变量,相互不影响,所以即使函数同时被调用,依然每次都能得到正确的结果。所以上面列举的函数 func()就是一个非常标准的绝对可重入函数,函数内部仅操作了函数内定义的局部变量,除了使用栈上的变量以外不依赖于任何环境变量,这样的函数就是 purecode(纯代码)可重入,可以允许该函数的多个副本同时在运行,由于它们使用的是分离的栈,所以不会相互干扰!

总结下绝对可重入函数的特点:

⚫ 函数内所使用到的变量均为局部变量,换句话说,该函数内的操作的内存地址均为本地栈地址;

⚫ 函数参数和返回值均是值类型;

⚫ 函数内调用的其它函数也均是绝对可重入函数。

带条件的可重入函数

带条件的可重入函数通常需要满足一定的条件时才是可重入函数,我们来看一个不可重入函数的例子,如下所示:

static int glob = 0;

static void func(int loops)

{

int local;

int j;

for (j = 0; j < loops; j++) {

local = glob;

local++;

glob = local;

}

}

当多个执行流同时调用该函数,全局变量 glob 的最终值将不得而知,最终可能会得不到正确的结果,因为全局变量 glob 将成为多个线程间的共享数据,它们都会对 glob 变量进行读写操作、会导致数据不一致的问题,这个函数就是典型的不可重入函数,函数运行需要读取、修改全局变量 glob,该变量并非在函数自己的栈上,意味着该函数运行依赖于外部环境变量。

但如果对上面的函数进行修改,函数 func()内仅读取全局变量 glob 的值,而不更改它的值:

static int glob = 0;

static void func(int loops)

{

int local;

int j;

for (j = 0; j < loops; j++) {

local = glob;

local++;

printf("local=%d\n", local);

}

}

修改完之后,函数 func()内仅读取了变量 glob,而并未更改 glob 的值,那么此时函数 func()就是一个可重入函数了;但是这里需要注意,它需要满足一个条件,这个条件就是:当多个执行流同时调用函数 func()时,全局变量 glob 的值绝对不会在其它某个地方被更改;譬如线程 1 和线程 2 同时调用了函数 func(),但是另一个线程 3 在线程 1 和线程 2 同时调用了函数 func()的时候,可能会发生更改变量 glob 值的情况,如果是这样,那么函数 func()依然是不可重入函数。这就是有条件的可重入函数的概念,这通常需要程序员本身去规避这类问题,标准 C 语言函数库中也存在很多这类带条件的可重入函数,后面给大家看一下。

再来看一个例子:

static void func(int *arg)

{

int local = *arg;

int j;

for (j = 0; j < 10; j++)

local++;

*arg = local;

}

这是一个参数为引用类型的函数,传入了一个指针,并在函数内部读写该指针所指向的内存地址,该函数是一个可重入函数,但同样需要满足一定的条件;如果多个执行流同时调用该函数时,所传入的指针是共享变量的地址,那么在这种情况,最终可能得不到预期的结果;因为在这种情况下,函数 func()所读写的便是多个执行流的共享数据,会出现数据不一致的情况,所以是不安全的。

但如果每个执行流所传入的指针是其本地变量(局部变量)对应的地址,那就是没有问题的,所以呢,这个函数就是一个带条件的可重入函数。

很多的 C 库函数有两个版本:可重入版本和不可重入版本,可重入版本函数其名称后面加上了“_r”,用于表明该函数是一个可重入函数;而不可重入版本函数其名称后面没有“_r”,前面章节内容中也已经遇到过很多次了,譬如 asctime()/asctime_r()、ctime()/ctime_r()、localtime()/localtime_r()等。

通过 man 手册可以查询到它们“ATTRIBUTES”信息,譬如执行"man 3 ctime",在帮助页面上往下翻便可以找到,如下所示:

可以看到上图中有些函数 Value 这栏会显示 MT-Unsafe、而有些函数显示的却是 MT-Safe。MT 指的是 multithreaded(多线程),所以 MT-Unsafe 就是多线程不安全、MT-Safe 指的是多线程安全,通常习惯上将MT-Safe 和 MT-Unsafe 称为线程安全或线程不安全。

Value 值为 MT-Safe 修饰的函数表示该函数是一个线程安全函数,使用 MT-Unsafe 修饰的函数表示它是一个线程 不安全函数,下一小节会给大家介绍什么是线程安全函数。从上图可以看出,asctime_r()/ctime_r()/gmtime_r()/localtime_r()这些可重入函数都是线程安全函数,但这些函数都是带条件的可重入函数,可以发现在 MT-Safe 标签后面会携带诸如 env 或 locale 之类的标签,这其实就表示该函数需要在满足env 或 locale 条件的情况下才是可重入函数;如果是绝对可重入函数,MT-Safe 标签后面不会携带任何标签,譬如数学库函数 sqrt:

线程安全函数

了解了可重入函数之后,再来看看线程安全函数。

一个函数被多个线程(其实也是多个执行流,但是不包括由信号处理函数所产生的执行流)同时调用时,它总会一直产生正确的结果,把这样的函数称为线程安全函数。线程安全函数包括可重入函数,可重入函数是线程安全函数的一个真子集,也就是说可重入函数一定是线程安全函数,但线程安全函数不一定是可重入函数,它们之间的关系如下:

譬如下面这个函数是一个不可重入函数,同样也是一个线程不安全函数(上小节的最后一个例子):

static int glob = 0;

static void func(int loops)

{

int local;

int j;

for (j = 0; j < loops; j++) {

local = glob;

local++;

glob = local;

}

}

如果对该函数进行修改,使用线程同步技术(譬如互斥锁)对共享变量 glob 的访问进行保护,在读写该变量之前先上锁、读写完成之后在解锁。这样,该函数就变成了一个线程安全函数,但是它依然不是可重入函数,因为该函数更改了外部全局变量的值。

也就是说,不可重入函数是线程不安全的,但是我们可以通过同步技术来使其变成线程安全的函数。注意区分这两者的概念。

至于线程同步技术,可参考这些文章:

贴上来。。。

补充:

拿来用。

补充

编译时报错:

undefined reference to 'pthread_create'

参考:

Linux下undefined reference to ‘pthread_create’问题解决-CSDN博客

问题原因:
pthread 库不是 Linux 系统默认的库,连接时需要使用静态库 libpthread.a,所以在使用pthread_create()创建线程,以及调用 pthread_atfork()函数建立fork处理程序时,需要链接该库。
问题解决:
在编译中要加 -lpthread参数
gcc thread.c -o thread -lpthread
thread.c为你些的源文件,不要忘了加上头文件#include<pthread.h>

  • 11
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值