7. 线程

线程是Linux系统轻量级并发执行单元,隶属于进程内部的独立执行流,依托进程资源运行、切换开销远低于进程, 是实现高性能并发编程、提升程序运行效率的核心技术,也是后端服务、嵌入式开发、数据处理等场景的必备技能。

7.1. 线程的基本概念

Linux系统中,进程是资源分配的最小单位,线程是CPU调度与执行的最小单位,线程也被称作轻量级进程(LWP), 是进程内部的独立执行序列。进程拥有独立的地址空间、文件描述符、内存资源等,进程切换时需要完整保存/恢复虚拟地址空间、寄存器、内核状态, 开销极大;而线程依托进程存在,共享进程的全局变量、文件描述符、信号处理方式、内存空间,仅独享栈空间、程序计数器、寄存器, 线程切换仅需保存私有上下文,开销远低于进程切换,这也是线程诞生的核心意义。

线程的本质是一个进程内部的一个控制序列,它是进程里面的东西,一个进程可以拥有一个线程或者多个线程。 它们的关系就如图所示:

进程与线程

回顾一下进程相关的知识:当进程执行fork()函数创建一个进程时,将创建出该进程的一个副本。 这个新进程拥有自己的变量和自己的PID,它的执行几乎完全独立于父进程, 这样得到一个新的进程开销是非常大的。而当在进程中创建一个新线程时,新的执行线程将拥有自己的栈, 但与它的创建者共享全局变量、文件描述符、信号处理函数和当前目录状态。 也就是说,它只使用当前进程的资源,而不是产生当前进程的副本。

7.2. 线程与进程对比

Linux系统中的每个进程都有独立的地址空间,一个进程崩溃后, 在系统的保护模式下并不会对系统中其它进程产生影响,而线程只是一个进程内部的一个控制序列, 当进程崩溃后,线程也随之崩溃,所以一个多进程的程序要比多线程的程序健壮,但在进程切换时, 耗费资源较大,效率要差一些。这就使得在某些场合下对于一些要求同时进行并且又要共享某些变量的并发操作, 只能用线程,不能用进程。

线程与进程对比如下:

进程vs线程核心对比

对比维度

进程

线程

资源归属

独立地址空间、独享所有资源

共享进程资源,仅独享栈/寄存器

切换开销

极大,需完整切换虚拟空间

极小,仅切换私有上下文

健壮性

进程崩溃不影响其他进程

线程崩溃会导致整个进程终止

通信方式

依赖IPC机制(管道、消息队列等)

直接共享全局变量,需同步机制保障安全

创建销毁

开销大、速度慢

开销小、速度快

7.3. 多线程应用场景

  • 需要高并发、低切换开销的场景,如网络服务、数据处理;

  • 多任务共享同一批资源,无需独立地址空间的场景;

  • 追求程序简洁性、模块化的并发设计,单CPU机器也可高效运行。

7.4. 线程函数说明

Linux遵循POSIX标准,采用pthread线程库实现多线程编程,该库可移植性极强, 使用时必须包含头文件<pthread.h>,编译时需添加-lpthread链接选项(部分编译器支持-pthread), pthread库函数错误码通过返回值返回,不使用errno,无法用perror打印错误信息。

7.4.1. pthread_create函数

pthread_create函数是用于创建一个线程的,创建线程实际上就是确定调用该线程函数的入口点, 在线程创建后,就开始运行相关的线程函数。函数原型如下:

1
2
3
4
#include <pthread.h>

int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
                    void *(*start_routine) (void *), void *arg);

参数说明:

  • thread:指向线程标识符的指针。

  • attr:设置线程属性,具体内容在下一小节讲解。

  • start_routine:start_routine是一个函数指针,指向要运行的线程入口,即线程运行时要执行的函数代码。

  • arg:运行线程时传入的参数。

  • 返回值:若线程创建成功,则返回0。若线程创建失败,则返回对应的错误代码。

7.4.1.1. 线程属性

上面pthread_create中需要以线程属性作为输入参数,在Linux中线程属性结构如下:

typedef struct
{
    int                   etachstate;      //线程的分离状态
    int                   schedpolicy;     //线程调度策略
    structsched_param     schedparam;      //线程的调度参数
    int                   inheritsched;    //线程的继承性
    int                   scope;           //线程的作用域
    size_t                guardsize;       //线程栈末尾的警戒缓冲区大小
    int                   stackaddr_set;   //线程的栈设置
    void*                 stackaddr;       //线程栈的位置
    size_t                stacksize;       //线程栈的大小
}pthread_attr_t;

线程的属性非常多,而且其属性值不能直接设置,须使用相关函数进行操作。线程属性主要包括如下属性: 作用域(scope)、栈大小(stacksize)、栈地址(stackaddress)、优先级(priority)、 分离的状态(detachedstate)、调度策略和参数(scheduling policy and parameters)。 默认的属性为非绑定、非分离、1M的堆栈大小、与父进程同样级别的优先级。 下面简单讲解一下与线程属性相关的API接口:

API

描述

pthread_attr_init()

初始化一个线程对象的属性

pthread_attr_destroy()

销毁一个线程属性对象

pthread_attr_getaffinity_np()

获取线程间的CPU亲缘性

pthread_attr_setaffinity_np()

设置线程的CPU亲缘性

pthread_attr_getdetachstate()

获取线程分离状态属性

pthread_attr_setdetachstate()

修改线程分离状态属性

pthread_attr_getguardsize()

获取线程的栈保护区大小

pthread_attr_setguardsize()

设置线程的栈保护区大小

pthread_attr_getscope()

获取线程的作用域

pthread_attr_setscope()

设置线程的作用域

pthread_attr_getstack()

获取线程的堆栈信息(栈地址和栈大小)

pthread_attr_setstack()

设置线程堆栈区

pthread_attr_getstacksize()

获取线程堆栈大小

pthread_attr_setstacksize()

设置线程堆栈大小

pthread_attr_getschedpolicy()

获取线程的调度策略

pthread_attr_setschedpolicy()

设置线程的调度策略

pthread_attr_getschedparam()

获取线程的调度优先级

pthread_attr_setschedparam()

设置线程的调度优先级

pthread_attr_getinheritsched()

获取线程是否继承调度属性

pthread_attr_setinheritsched()

设置线程是否继承调度属性

无其他特别需求,是可以不需要考虑线程相关属性的,使用默认的属性即可。

7.4.2. pthread_join函数

默认线程为可结合(joinable)状态,线程退出后不会自动释放资源,需调用pthread_join阻塞等待线程终止,并回收线程占用的资源,避免僵尸线程。

1
2
3
#include <pthread.h>

int pthread_join(pthread_t tid, void **rval_ptr);

参数说明:

  • thread: 线程标识符,即线程ID,标识唯一线程。

  • retval: 用户定义的指针,用来存储被等待线程的返回值。

需要注意的是一个可结合状态的线程所占用的内存仅当有线程对其执行立pthread_join()后才会释放,因此为了避免内存泄漏, 所有线程的终止时,要么已被设为DETACHED,要么使用pthread_join()来回收资源。

7.4.3. pthread_exit函数

线程执行完逻辑后,可通过pthread_exit主动退出,禁止在线程中调用exit,exit会终止整个进程,导致所有线程全部退出。

函数原型:

1
2
3
#include <pthread.h>

void pthread_exit(void *retval);

参数说明:

  • retval:如果retval不为空,则会将线程的退出值保存到retval中,如果不关心线程的退出值,形参为NULL即可。

一般情况下,进程中各个线程的运行是相互独立的,线程的终止并不会相互通知,也不会影响其他的线程, 终止的线程所占用的资源不会随着线程的终止而归还系统,而是仍为线程所在的进程持有, 这是因为一个进程中的多个线程是共享数据段的。

7.4.4. pthread_detach函数

除此之外线程也可以调用pthread_detach()函数将此线程设置为分离状态,设置为分离状态的线程在线程结束时, 操作系统会自动收回它所占的资源。设置为分离状态的线程,不能再调用pthread_join()等待其结束。

1
2
3
4
5
6
#include <pthread.h>

int pthread_detach(pthread_t tid);

// 参数:tid 目标线程ID
// 返回值:成功返回0,失败返回非0错误码

7.5. 线程的调度策略

线程属性里包含了调度策略配置,POSIX 标准指定了三种调度策略:

  • 分时调度策略:SCHED_OTHER。这是线程属性的默认值,另外两种调度方式只能用于以超级用户权限运行的进程, 因为它们都具备实时调度的功能,但在行为上略有区别。

  • 实时调度策略:先进先出方式调度(SCHED_FIFO)。基于队列的调度程序,对于每个优先级都会使用不同的队列, 先进入队列的线程能优先得到运行,线程会一直占用CPU,直到有更高优先级任务到达或自己主动放弃CPU使用权。

  • 实时调度策略:时间片轮转方式调度(SCHED_RR)。与 FIFO相似,不同的是前者的每个线程都有一个执行时间配额, 当采用SHCED_RR策略的线程的时间片用完,系统将重新分配时间片, 并将该线程置于就绪队列尾,并且切换线程,放在队列尾保证了所有具有相同优先级的RR线程的调度公平。

与调度相关的API接口如下:

int pthread_attr_setinheritsched(pthread_attr_t *attr, int inheritsched);
int pthread_attr_getinheritsched(const pthread_attr_t *attr, int *inheritsched);

int pthread_attr_setschedpolicy(pthread_attr_t *attr, int policy);
int pthread_attr_getschedpolicy(const pthread_attr_t *attr, int *policy);

若函数调用成功返回0,否则返回对应的错误代码。

参数说明:

  • attr:指向一个线程属性的指针。

  • inheritsched:线程是否继承调度属性,可选值为:

  • PTHREAD_INHERIT_SCHED:调度属性将继承于创建的线程,attr中设置的调度属性将被忽略。

  • PTHREAD_EXPLICIT_SCHED:调度属性将被设置为attr中指定的属性值。

  • policy:可选值为线程的三种调度策略,SCHED_OTHER、SCHED_FIFO、SCHED_RR。

7.6. 线程的优先级

顾名思义,线程优先级就是这个线程得到运行的优先顺序,在Linux系统中,优先级数值越小, 线程优先级越高。Linux会根据线程的优先级对线程进行调度,遵循线程属性中指定的调度策略。

获取、设置线程静态优先级(staticpriority)可以使用以下函数,注意,是静态优先级, 当线程的调度策略为SCHED_OTHER时,其静态优先级必须设置为0。该调度策略是Linux系统调度的默认策略, 处于0优先级别的这些线程会按照动态优先级被调度,之所以被称为“动态”,是因为它会随着线程的运行, 根据线程的表现而发生改变,而动态优先级起始于线程的nice值,且每当一个线程已处于就绪态但被调度器调度无视时, 其动态优先级会自动增加一个单位,这样能保证这些线程竞争CPU的公平性。

线程的静态优先级之所以被称为“静态”,是因为只要你不强行使用相关函数修改它, 它是不会随着线程的执行而发生改变,静态优先级决定了实时线程的基本调度次序,它们是在实时调度策略中使用的。

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

参数说明:

  • attr:指向一个线程属性的指针。

  • param:静态优先级数值。

线程优先级有以下特点:

  • 新线程的优先级为默认为0。

  • 新线程不继承父线程调度优先级(PTHREAD_EXPLICIT_SCHED)

  • 当线程的调度策略为SCHED_OTHER时,不允许修改线程优先级,仅当调度策略为实时(即SCHED_RR或SCHED_FIFO)时才有效, 并可以在运行时通过pthread_setschedparam()函数来改变,默认为0。

7.7. 线程栈

线程栈是非常重要的资源,它可以存放函数形参、局部变量、线程切换现场寄存器等数据, 在前文我们也说过了,线程使用的是进程的内存空间,那么一个进程有n个线程,默认的线程栈大小是1M, 那么就有可能导致进程的内存空间是不够的,因此在有多线程的情况下,我们可以适当减小某些线程栈的大小, 防止进程的内存空间不足。而某些线程可能需要完成很大量的工作,或者线程调用的函数会分配很大的局部变量, 亦或是函数调用层次很深时,需要的栈空间可能会很大,那么也可以增大线程栈的大小。

设置、获取线程栈大小可以使用以下函数:

int pthread_attr_setstacksize(pthread_attr_t *attr, size_t stacksize);
int pthread_attr_getstacksize(const pthread_attr_t *attr, size_t *stacksize);

参数说明:

  • attr:指向一个线程属性的指针。

  • stacksize:线程栈的大小。

7.8. 线程示例

我们在日常使用的情况下,若非特别需要,几乎不需要修改线程的属性的,我们在此处做一个线程的实验, 实验中创建一个进程,线程的属性是默认属性,在线程执行完毕后就退出,代码如下:

线程实验测试(base_code/system_programing/thread/sources/thread.c文件)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>

/*要执行的线程*/
void *test_thread(void *arg)
{
    int num = (unsigned long long)arg; /** sizeof(void*) == 8 and sizeof(int) == 4 (64 bits) */

    printf("This is test thread, arg is %d\n", num);
    sleep(5);
    /*退出线程*/
    pthread_exit(NULL);
}


int main(void)
{
    pthread_t thread;
    void *thread_return;
    int arg = 520;
    int res;

    printf("start create thread\n");

    /*创建线程,线程为test_thread函数*/
    res = pthread_create(&thread, NULL, test_thread, (void*)(unsigned long long)(arg));
    if(res != 0)
    {
        printf("create thread fail\n");
        exit(res);
    }

    printf("create treads success\n");
    printf("waiting for threads to finish...\n");

    /*等待线程终止*/
    res = pthread_join(thread, &thread_return);
    if(res != 0)
    {
        printf("thread exit fail\n");
        exit(res);
    }

    printf("thread exit ok\n");

    return 0;
}

代码的分析如下:

  • 第8~16行,定义test_thread函数作为线程要执行的函数,函数内部的操作是打印传入的arg参数, 然后睡眠一定的时间,最后调用thread_exit退出线程。

  • 第29行,调用pthread_create函数创建线程,传入的线程函数指针为test_thread, 并且传入了一个函数参数arg(520),创建后线程将会开始执行test_thread的代码。

  • 第40行,创建线程后调用 pthread_join等待线程退出。

要注意的是,本示例中需要在Makefile中添加lpthread链接库的内容:

添加lpthread链接(base_code/system_programing/thread/sources/Makefile文件)
LINK = -lpthread

7.8.1. 实验操作

进入 system_programing/thread 目录下执行make编译源码,然后运行,实验现象如下:

# 以下操作在 system_programing/thread代码目录进行
make

# 运行
./build/thread_demo

# 以下是运行的输出
start create thread
create treads success
waiting for threads to finish...
This is test thread, arg is 520

# 等待一段时间线程退出
thread exit ok

从实验现象可看到,进程会停留在pthread_join处等待对应的线程结束后再执行后面的代码。

配套的示例中还提供了线程属性的试验,感兴趣可以查看base_code/system_programing/thread_attr目录的内容学习。