linux学习进展 线程

在前两节的学习中,我们掌握了进程间通讯(IPC)的两种核心方式------共享内存和消息队列,它们解决了不同进程间的数据交互问题。但进程作为Linux中资源分配的最小单位,创建、销毁和调度的开销较大,在高并发场景(如Web服务器、高频数据处理)中,多进程方案会浪费大量系统资源,导致效率下降。本节课我们将学习一种更轻量级的并发执行单元------线程,它是程序执行的最小单位,共享进程资源、开销极低,是实现高并发的核心技术,也是后续学习线程同步、线程池的基础。

本文将从线程的核心概念、Linux底层实现、核心接口(POSIX线程库)、实操代码示例、线程安全及常见问题,逐步拆解线程的使用逻辑,帮助大家彻底理解线程与进程的区别,掌握多线程编程的基础技巧。

一、线程核心概念与本质

线程(Thread),又称轻量级进程(Lightweight Process, LWP),是进程内的一条独立执行流,也是操作系统进行CPU调度的最小单位。一个进程可以包含多个线程,所有线程共享该进程的全部资源(如虚拟地址空间、文件描述符、代码段、数据段、堆内存等),但每个线程拥有独立的执行上下文(程序计数器、寄存器、栈空间等),能够独立占用CPU执行。

简单来说,进程是"资源分配的容器",线程是"容器内的执行主体"。打个比方,进程就像一家独立的公司,拥有自己的办公场地(内存资源)、财务账户(文件描述符);而线程就是公司里的各个部门,共享公司的所有资源,各自独立开展工作(执行任务),部门的创建、解散(线程的创建、销毁)开销远小于公司本身(进程)。

1. 线程与进程的核心区别(必记)

为了更清晰地理解线程,我们对比线程与进程的核心差异,这也是面试和学习中的重点,结合底层逻辑总结如下:

对比维度 进程(Process) 线程(Thread)
资源分配 操作系统资源分配的最小单位,拥有独立的虚拟地址空间、文件描述符、PCB(进程控制块) 不独立分配资源,共享所属进程的所有资源,仅拥有独立的栈、程序计数器、寄存器
调度单位 操作系统调度的基本单位,但调度开销大(需切换地址空间、刷新TLB) 操作系统CPU调度的最小单位,调度开销小(无需切换地址空间,缓存局部性更好)
创建/销毁开销 大,需分配内存、复制父进程资源(如fork()需写时复制地址空间) 小,仅需分配栈空间和初始化线程控制块(TCB),无需分配新的地址空间
通信方式 需通过IPC机制(管道、共享内存、消息队列等),开销大、操作复杂 可直接访问进程的全局变量、堆内存,通信简单高效,但需解决同步问题
独立性 高,进程间地址空间独立,一个进程崩溃不影响其他进程 低,线程共享进程资源,一个线程崩溃可能导致整个进程退出(如栈溢出)
并发能力 低,进程切换开销大,适合少量并发场景 高,线程切换开销小,适合高并发场景(如Web服务器、高频任务处理)

2. 线程的核心特征

轻量级:创建、销毁和调度的开销远小于进程,是实现高并发的关键;

资源共享:同一进程内的所有线程共享进程的虚拟地址空间、文件描述符、信号处理程序、当前工作目录等资源,无需额外通信机制即可共享数据;

独立执行:每个线程拥有独立的栈(用户态栈+内核态栈)、程序计数器(PC)、寄存器集合,能够独立占用CPU执行,执行顺序由操作系统调度决定;

调度由内核负责:Linux内核将线程视为"轻量级进程",统一调度,线程的优先级可单独设置(需权限);

线程安全风险:多个线程共享资源时,若同时读写会导致数据竞争(竞态条件),需通过同步机制(互斥锁、条件变量等)保证线程安全。

3. 线程的适用场景

线程的核心优势是轻量、高效,适合以下场景:

高并发场景:如Web服务器,需同时处理大量客户端请求,每个请求用一个线程处理,开销远小于多进程;

IO密集型任务:如文件读写、网络通信,线程在等待IO完成时会被阻塞,此时CPU可调度其他线程执行,提高CPU利用率;

数据共享频繁的场景:如多任务协作处理同一份数据,线程可直接访问共享内存,无需额外IPC通信,效率更高;

实时性要求较高的场景:线程调度开销小,能快速响应任务,适合实时数据处理。

补充:CPU密集型任务(如大规模计算)中,多线程的优势不明显,因为CPU一直处于忙碌状态,线程切换反而会增加开销,此时更适合多进程或单线程优化。

二、Linux线程的底层实现(重点)

与Windows等操作系统不同,Linux内核本身不直接区分"线程"和"进程",内核调度的最小单位是"任务(task_struct)"------每个任务对应一个task_struct结构体,用于描述任务的执行状态、资源占用等信息。无论是进程还是线程,在Linux内核中都以task_struct的形式存在,核心区别在于"是否共享资源"。

1. 底层核心结构体:task_struct

task_struct是Linux内核中描述任务(进程/线程)的核心结构体,包含以下关键信息,决定了任务的运行状态和资源占用:

进程ID(PID):内核标识任务的唯一ID,每个task_struct都有唯一的PID;

线程组ID(TGID):同一进程内的所有线程共享同一个TGID,这个TGID就是该进程的PID(主线程的PID),因此ps命令默认只显示进程(主线程)的信息;

内存描述符(mm_struct):指向任务的虚拟地址空间,进程的task_struct拥有独立的mm_struct,而线程的task_struct共享所属进程的mm_struct;

文件描述符表(files_struct):记录任务打开的文件,线程共享进程的文件描述符表;

调度属性:包括优先级、调度策略等,线程可单独设置调度属性(需root权限);

执行上下文:包括程序计数器(PC)、寄存器集合、栈指针等,线程拥有独立的执行上下文;

信号处理信息:信号处理程序是进程级共享的,但每个线程可设置独立的信号掩码(阻塞不同的信号)。

2. 线程的实现方式:轻量级进程(LWP)

Linux中的线程本质是"共享资源的轻量级进程",其实现依赖clone()系统调用------创建线程时,通过clone()传递特定的标志位,让新创建的task_struct共享父任务(主线程)的资源,而非创建新的资源:

创建进程(fork()):fork()底层调用clone(),传递的标志位不共享mm_struct、files_struct等核心资源,因此新创建的task_struct拥有独立的地址空间,成为一个新进程;

创建线程(pthread_create()):pthread_create()底层调用clone(),传递CLONE_VM(共享虚拟地址空间)、CLONE_FS(共享文件系统信息)、CLONE_FILES(共享文件描述符表)等标志位,新创建的task_struct共享父任务的所有核心资源,成为一个线程。

补充:用户态的线程管理由POSIX线程库(pthread库)负责,内核只负责调度轻量级进程(LWP)。pthread库会维护线程控制块(TCB,struct pthread),记录线程的用户态信息(如线程ID、状态、栈信息等),并通过clone()与内核交互,实现线程的创建、调度和销毁。

3. 线程ID的区别(必避坑)

Linux中存在两种线程ID,容易混淆,必须明确区分:

内核线程ID(TID):内核分配给每个task_struct的唯一ID,即PID(因为内核不区分进程和线程),可通过syscall(SYS_gettid)获取,用于内核调度和管理;

用户态线程ID(pthread_t):由pthread库分配,是用户态线程的唯一标识,本质是线程控制块(TCB)的地址,用于用户态程序中标识线程(如 pthread_join()、pthread_mutex_lock() 等接口使用)。

注意:同一进程内的线程,内核线程ID(TID)各不相同,但用户态线程ID(pthread_t)也各不相同;不同进程的线程,内核线程ID(TID)和用户态线程ID(pthread_t)都可能重复,因此不能通过线程ID跨进程标识线程。

三、POSIX线程库(pthread库)核心接口

Linux中没有专门的系统调用直接创建线程,而是通过POSIX线程库(pthread库)提供的接口实现线程的管理,所有接口均以pthread_开头,需包含头文件 <pthread.h>,且编译时需添加 -lpthread 选项(链接pthread库)。

重点说明:pthread库接口的错误处理与系统调用不同------系统调用(如fork()、msgget())通过返回-1并设置全局errno表示错误,而pthread接口直接通过返回值返回错误码(0表示成功,非0表示错误),无需依赖errno。

1. 核心接口详解(必掌握)

(1)pthread_create():创建线程

作用:在当前进程中创建一个新的线程,新线程会立即执行指定的入口函数,主线程继续执行后续代码。

cpp 复制代码
#include <pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, 
                   void *(*start_routine)(void*), void *arg);
  • 参数说明:

    • thread:输出参数,指向pthread_t类型变量的指针,用于存储新创建线程的用户态线程ID;

    • attr:线程属性,通常设为NULL,使用默认属性(如默认栈大小、默认分离状态);若需自定义属性(如设置栈大小、分离状态),需先初始化pthread_attr_t结构体;

    • start_routine:线程入口函数指针,类型为void* (*)(void*),线程启动后会自动调用该函数,函数返回值为线程的退出状态,参数为arg;

    • arg:传递给线程入口函数的参数,若需传递多个参数,可封装为结构体后传递指针。

  • 返回值:成功返回0,失败返回非0错误码(如EAGAIN:系统资源不足,无法创建线程;EINVAL:线程属性无效)。

  • 注意:

    • 线程入口函数的返回值和参数必须是void*类型,若传递基本类型(如int),需进行强制类型转换;

    • 新线程创建后,与主线程并发执行,执行顺序由操作系统调度决定,无法预测;

    • 若主线程先于子线程退出,且未回收子线程资源,子线程会成为"僵尸线程",占用系统资源。

(2)pthread_self():获取当前线程ID

作用:获取当前调用该函数的线程的用户态线程ID(pthread_t),用于标识当前线程。

cpp 复制代码
#include <pthread.h>
pthread_t pthread_self(void);
  • 返回值:当前线程的用户态线程ID(pthread_t)。

  • 注意:该函数返回的是用户态线程ID,而非内核线程ID(TID);若需获取内核线程ID,需调用syscall(SYS_gettid)(需包含<sys/syscall.h>)。

(3)pthread_join():回收线程资源(阻塞)

作用:阻塞当前线程,等待指定的子线程退出,回收子线程的资源(如栈空间、TCB),并获取子线程的退出状态,避免僵尸线程。

cpp 复制代码
#include <pthread.h>
int pthread_join(pthread_t thread, void **retval);
  • 参数说明:

    • thread:需要回收的子线程的用户态线程ID(pthread_t);

    • retval:输出参数,指向void*类型的指针,用于存储子线程入口函数的返回值(即线程的退出状态);若无需获取退出状态,可设为NULL。

  • 返回值:成功返回0,失败返回非0错误码(如EINVAL:线程不可回收;ESRCH:线程不存在)。

  • 注意:

    • pthread_join()是阻塞函数,调用后当前线程会一直等待,直到指定子线程退出;

    • 一个线程只能被join一次,若多次join同一个线程,会返回错误;

    • 若子线程设置为"分离状态"(detached),则无法用pthread_join()回收,子线程退出后会自动释放资源。

(4)pthread_exit():线程退出

作用:让当前线程主动退出,释放自身的栈资源,同时设置线程的退出状态,供pthread_join()获取。

cpp 复制代码
#include <pthread.h>
void pthread_exit(void *retval);
  • 参数说明:retval:线程的退出状态,会被pthread_join()获取,若无需设置退出状态,可设为NULL。

  • 注意:

    • pthread_exit()仅退出当前线程,不会影响其他线程(包括主线程);

    • 若主线程调用pthread_exit(),会等待所有子线程退出后再退出进程;若主线程调用exit(),会立即终止整个进程,所有子线程也会被强制终止;

    • 线程入口函数中return NULL,等价于调用pthread_exit(NULL)。

(5)pthread_detach():设置线程为分离状态

作用:将指定线程设置为"分离状态",分离状态的线程退出后,系统会自动回收其资源,无需调用pthread_join()回收,避免僵尸线程。

cpp 复制代码
#include <pthread.h>
int pthread_detach(pthread_t thread);
  • 参数说明:thread:需要设置为分离状态的线程的用户态线程ID。

  • 返回值:成功返回0,失败返回非0错误码。

  • 注意:

    • 线程一旦设置为分离状态,就无法再恢复为可连接状态(joinable),也无法用pthread_join()回收;

    • 适合不需要获取线程退出状态、且无需等待线程完成的场景(如后台任务线程);

    • 也可在创建线程时,通过设置线程属性(pthread_attr_t),直接创建分离状态的线程。

(6)pthread_cancel():取消线程

作用:向指定线程发送取消请求,请求该线程退出,但线程是否响应取消请求,取决于线程的取消状态和取消点。

cpp 复制代码
#include <pthread.h>
int pthread_cancel(pthread_t thread);
  • 参数说明:thread:需要取消的线程的用户态线程ID。

  • 返回值:成功返回0,失败返回非0错误码。

  • 注意:

    • pthread_cancel()只是发送取消请求,并非立即终止线程,线程会在"取消点"(如系统调用、pthread_testcancel())处响应取消请求;

    • 若线程设置为"不可取消"状态(默认是可取消),则不会响应取消请求;

    • 线程被取消后,退出状态为PTHREAD_CANCELED(值为(void*)-1),可通过pthread_join()获取。

四、实操代码示例(多线程基础)

下面通过两个实操示例,帮助大家掌握pthread库核心接口的使用:示例1实现简单的多线程并发执行,示例2实现线程的回收、退出和分离状态设置,贴合学习场景,可直接编译运行。

示例1:简单多线程并发执行

创建3个线程,每个线程执行不同的任务(打印线程ID和任务内容),主线程等待所有子线程完成后退出,演示线程的创建、并发执行和资源回收。

cpp 复制代码
#include <pthread.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

// 线程1入口函数:打印线程ID和计数
void* thread_func1(void* arg) {
    printf("线程1(ID:%lu)开始执行,参数:%s\n", pthread_self(), (char*)arg);
    for (int i = 0; i < 3; i++) {
        printf("线程1:计数%d\n", i+1);
        sleep(1); // 模拟任务执行耗时
    }
    printf("线程1执行完成,退出\n");
    return (void*)1; // 设置退出状态为1
}

// 线程2入口函数:打印线程ID和信息
void* thread_func2(void* arg) {
    printf("线程2(ID:%lu)开始执行,参数:%d\n", pthread_self(), *(int*)arg);
    sleep(2);
    printf("线程2执行完成,退出\n");
    pthread_exit((void*)2); // 设置退出状态为2,等价于return (void*)2
}

// 线程3入口函数:打印线程ID,演示分离状态
void* thread_func3(void* arg) {
    printf("线程3(ID:%lu)开始执行,参数:%s\n", pthread_self(), (char*)arg);
    sleep(4);
    printf("线程3执行完成,退出(分离状态,自动释放资源)\n");
    return NULL;
}

int main() {
    pthread_t tid1, tid2, tid3;
    char* msg1 = "线程1的参数";
    int msg2 = 100;
    char* msg3 = "线程3的参数";
    int ret;
    void* exit_status;

    // 1. 创建线程1
    ret = pthread_create(&tid1, NULL, thread_func1, (void*)msg1);
    if (ret != 0) {
        fprintf(stderr, "创建线程1失败,错误码:%d\n", ret);
        exit(1);
    }

    // 2. 创建线程2
    ret = pthread_create(&tid2, NULL, thread_func2, (void*)&msg2);
    if (ret != 0) {
        fprintf(stderr, "创建线程2失败,错误码:%d\n", ret);
        exit(1);
    }

    // 3. 创建线程3,并设置为分离状态
    ret = pthread_create(&tid3, NULL, thread_func3, (void*)msg3);
    if (ret != 0) {
        fprintf(stderr, "创建线程3失败,错误码:%d\n", ret);
        exit(1);
    }
    pthread_detach(tid3); // 设置线程3为分离状态,无需join

    // 4. 主线程等待线程1和线程2退出,回收资源
    ret = pthread_join(tid1, &exit_status);
    if (ret != 0) {
        fprintf(stderr, "回收线程1失败,错误码:%d\n", ret);
        exit(1);
    }
    printf("线程1退出状态:%ld\n", (long)exit_status);

    ret = pthread_join(tid2, &exit_status);
    if (ret != 0) {
        fprintf(stderr, "回收线程2失败,错误码:%d\n", ret);
        exit(1);
    }
    printf("线程2退出状态:%ld\n", (long)exit_status);

    // 线程3是分离状态,无需join,主线程等待其执行完成(模拟)
    sleep(5);
    printf("主线程执行完成,退出\n");

    return 0;
}

示例2:线程同步入门(避免数据竞争)

多个线程共享全局变量,演示数据竞争问题,以及如何通过互斥锁(pthread_mutex_t)解决数据竞争,确保线程安全。这是多线程编程的核心难点,后续会详细讲解同步机制,此处先入门。

cpp 复制代码
#include <pthread.h>
#include <stdio.h>
#include <unistd.h>

int g_count = 0; // 全局变量,多个线程共享
pthread_mutex_t mutex; // 互斥锁,保护共享资源

// 线程入口函数:对全局变量进行累加
void* add_count(void* arg) {
    for (int i = 0; i < 10000; i++) {
        // 加锁:确保同一时间只有一个线程访问共享资源
        pthread_mutex_lock(&mutex);
        g_count++; // 临界区:访问共享资源的代码
        // 解锁:释放锁,允许其他线程访问
        pthread_mutex_unlock(&mutex);
        // 模拟任务耗时,放大数据竞争问题
        usleep(1);
    }
    return NULL;
}

int main() {
    pthread_t tid1, tid2;
    int ret;

    // 初始化互斥锁
    pthread_mutex_init(&mutex, NULL);

    // 创建两个线程,同时累加全局变量
    ret = pthread_create(&tid1, NULL, add_count, NULL);
    if (ret != 0) {
        fprintf(stderr, "创建线程1失败,错误码:%d\n", ret);
        return 1;
    }
    ret = pthread_create(&tid2, NULL, add_count, NULL);
    if (ret != 0) {
        fprintf(stderr, "创建线程2失败,错误码:%d\n", ret);
        return 1;
    }

    // 等待两个线程退出
    pthread_join(tid1, NULL);
    pthread_join(tid2, NULL);

    // 销毁互斥锁
    pthread_mutex_destroy(&mutex);

    // 打印最终结果:若不加锁,结果会小于20000(数据竞争);加锁后,结果等于20000
    printf("全局变量最终值:%d\n", g_count);

    return 0;
}

编译与运行步骤

  1. 保存代码为thread_demo1.c(示例1)、thread_demo2.c(示例2);

  2. 编译代码(必须添加-lpthread选项,链接pthread库): gcc thread_demo1.c -o thread1 -lpthread gcc thread_demo2.c -o thread2 -lpthread

  3. 运行程序: ./thread1:观察线程的并发执行、退出状态和分离状态的效果; ./thread2:对比加锁和不加锁(注释掉pthread_mutex_lock/unlock)的结果,理解数据竞争问题。

五、线程常见问题与避坑要点

多线程编程看似简单,但容易因细节问题导致程序异常、资源泄漏或数据错误,以下是新手最常遇到的问题及解决方案,务必牢记:

1. 僵尸线程(最常见)

问题:子线程退出后,主线程未调用pthread_join()回收其资源,子线程的TCB和栈空间无法释放,成为僵尸线程,长期积累会占用系统资源,导致无法创建新线程。

解决方案: 对于需要获取退出状态的线程,调用pthread_join()阻塞回收;对于不需要获取退出状态的线程,调用pthread_detach()设置为分离状态,让系统自动回收;主线程退出前,确保所有子线程都已退出(可通过pthread_join()批量回收)。

2. 数据竞争(线程安全问题)

问题:多个线程同时读写共享资源(如全局变量、堆内存),由于线程执行顺序不确定,导致数据覆盖、计算错误等问题(如示例2中不加锁的情况),这就是数据竞争(竞态条件)。

解决方案: 使用同步机制保护共享资源,如互斥锁(pthread_mutex_t)、条件变量(pthread_cond_t)、信号量等;尽量减少共享资源的使用,优先使用线程私有数据(TLS,pthread_key_create()等接口);若必须使用共享资源,确保同一时间只有一个线程访问(通过锁机制实现)。

3. 线程栈溢出

问题:每个线程拥有独立的栈空间(默认大小通常为8MB),若线程中递归调用过深、定义过大的局部变量,会导致栈溢出,进而导致线程崩溃,甚至整个进程退出。

解决方案: 避免递归调用过深,或优化递归逻辑(改为迭代);避免在栈上定义过大的局部变量(如大数组),可改为在堆上动态分配(malloc());通过线程属性(pthread_attr_t)自定义线程栈大小(pthread_attr_setstacksize())。

4. 主线程提前退出,子线程被强制终止

问题:主线程调用exit()或return退出,会立即终止整个进程,所有子线程无论是否执行完成,都会被强制终止,导致任务未完成。

解决方案: 主线程通过pthread_join()等待所有子线程退出后,再退出;若主线程需要提前退出,可调用pthread_exit(),此时主线程退出,但子线程会继续执行,直到完成后进程才会退出。

5. 线程ID混淆(用户态与内核态)

问题:误用用户态线程ID(pthread_t)和内核线程ID(TID),导致线程标识错误(如用pthread_t作为内核调度的依据)。

解决方案: 用户态编程中,使用pthread_t标识线程(如pthread_join()、pthread_cancel());若需获取内核线程ID,调用syscall(SYS_gettid),用于内核相关的调试(如ps -aL查看线程LWP);不要用pthread_t跨进程标识线程,不同进程的pthread_t可能重复。

6. 死锁

问题:多个线程互相等待对方释放资源(如线程A持有锁1,等待锁2;线程B持有锁2,等待锁1),导致所有线程陷入无限阻塞,无法继续执行,这是多线程同步中最严重的问题之一。

解决方案(后续会详细讲解,此处先掌握基础规避方法): 统一锁的获取顺序(如所有线程都先获取锁1,再获取锁2);避免长时间持有锁,尽量缩短临界区的代码长度;使用带超时的锁(pthread_mutex_timedlock()),避免无限阻塞。

六、总结与拓展

本节我们掌握了线程的核心概念、Linux底层实现、POSIX线程库的核心接口及实操技巧,核心总结如下:

线程是轻量级的执行单元,共享进程资源、调度开销小,是实现高并发的核心,与进程的核心区别在于"资源分配"和"调度开销";

Linux中线程本质是"共享资源的轻量级进程",内核通过task_struct管理,用户态通过pthread库实现线程的创建、回收、退出等操作;

核心接口:pthread_create(创建)、pthread_join(回收)、pthread_exit(退出)、pthread_detach(分离),编译时必须添加-lpthread选项;

多线程编程的核心难点是线程安全,需通过同步机制(如互斥锁)解决数据竞争、死锁等问题;

常见坑:僵尸线程、数据竞争、栈溢出、主线程提前退出,需针对性规避。

相关推荐
itzixiao2 小时前
L1-049 天梯赛座位分配(20 分)[java][python][c]
java·开发语言·python
HABuo2 小时前
【linux网络基础(二)】理解端口号&UDP、TCP协议&网络字节序
linux·服务器·c语言·网络·c++·ubuntu·centos
爱学习的小囧2 小时前
ESXi 存储路径丢失(PDL/APD)完整处置教程:分清类型再操作,一步不踩坑
linux·运维·服务器·网络·esxi·vmware
子非鱼@Itfuture2 小时前
ThreadLocal 是什么?如何用?以及最佳使用场景
java·开发语言·spring
杨凯凡2 小时前
【024】JVM 参数入门:堆、栈、元空间与典型模板
java·开发语言·jvm
久菜盒子工作室2 小时前
TCL是哪个板块的,去年大涨的原因是什么
人工智能·学习
笨鸟先飞的橘猫2 小时前
Mysql——MVCC学习
数据库·学习·mysql
不做超级小白2 小时前
Termux 完整安装与配置指南(2026.4.24最新版,从零到可用)
linux·手机
Lumos_7772 小时前
Linux -- 信号
linux·运维·服务器