线程基础:从进程到线程的进化

说实话,我第一次接触线程这个概念时,心里是有点抵触的。那时候我习惯了fork()创建进程,觉得进程模型挺清晰的——各干各的,互不干扰。直到我在一个网络服务器项目里,发现每来一个客户端就fork一个进程,内存开销大得吓人,进程间通信也搞得我焦头烂额。嗯,从那时起,我才真正理解了线程存在的意义。

线程是什么?

线程,说白了就是「轻量级进程」。你想想看,进程创建时要复制一大堆东西——地址空间、文件描述符表、信号处理函数……而线程呢?它只创建必要的执行上下文,比如栈、寄存器状态、程序计数器。其他东西,比如代码段、数据段、堆内存,统统和创建它的进程共享。

我习惯这样理解:进程是资源分配的最小单位,线程是CPU调度的最小单位。一个进程可以包含多个线程,这些线程共享进程的资源,但各自独立执行。

核心区别一句话:进程有独立的地址空间,线程共享地址空间。

线程的优势——为什么我们需要它?

我在做嵌入式Linux项目时,遇到过这样一个场景:需要同时采集传感器数据、处理用户输入、刷新显示界面。如果用多进程,光IPC(进程间通信)就能写出一堆bug。用线程就简单多了——共享内存,直接读写全局变量。

线程的优势主要体现在这几个方面:

  • 创建开销小——创建线程比创建进程快10-100倍。我实测过,在x86_64 Linux上,pthread_create大约需要几微秒,而fork需要几十微秒。
  • 通信效率高——共享内存,不需要管道、消息队列这些IPC机制。但要注意同步问题,这个后面会讲。
  • 资源占用少——线程只占栈空间和线程控制块,不像进程要复制整个地址空间。
  • 切换速度快——同一进程内的线程切换,不需要切换页表,TLB不用刷新。

我的经验:如果你需要频繁创建和销毁执行单元,或者需要大量数据共享,线程是更好的选择。但如果需要强隔离(比如一个子任务崩溃不影响其他任务),进程更合适。

三个核心API:pthread_create / pthread_exit / pthread_join

Linux下线程编程用的是POSIX线程库,也就是pthread。使用时要链接-lpthread。我刚开始学的时候,经常忘记加这个链接选项,编译报错半天找不到原因。

pthread_create——创建线程

函数原型长这样:

#include <pthread.h>

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

参数说明:

  • thread:输出参数,返回线程ID
  • attr:线程属性,传NULL使用默认属性
  • start_routine:线程入口函数,返回void*,参数void*
  • arg:传给线程函数的参数

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

来看个最简单的例子:

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

void *thread_func(void *arg) {
    int *num = (int *)arg;
    printf("子线程收到参数: %d\n", *num);
    return NULL;
}

int main() {
    pthread_t tid;
    int value = 42;

    if (pthread_create(&tid, NULL, thread_func, &value) != 0) {
        perror("pthread_create");
        return 1;
    }

    printf("主线程等待子线程...\n");
    pthread_join(tid, NULL);
    printf("子线程结束,主线程退出\n");
    return 0;
}

注意:传给线程的参数arg必须是线程安全的。我曾经犯过一个错误——在主线程栈上定义一个局部变量,然后传给子线程。结果主线程先退出了,子线程访问的是已经失效的栈内存。嗯,这种bug很难查。

pthread_exit——线程主动退出

线程退出有三种方式:

  1. 线程函数执行完return
  2. 调用pthread_exit()
  3. 被其他线程调用pthread_cancel()取消

pthread_exit的原型:

void pthread_exit(void *retval);

retval是线程的返回值,可以被pthread_join获取。注意:retval指向的内存必须是有效的,不能是线程栈上的局部变量。

我习惯这样用:

void *worker(void *arg) {
    // 做一些工作...
    int *result = malloc(sizeof(int));
    *result = 100;
    pthread_exit((void *)result);
}

小技巧:在主线程中调用pthread_exit(),主线程会退出,但进程不会结束,其他子线程会继续运行。这和return不一样,return会导致整个进程退出。

pthread_join——等待线程结束

这个函数的作用和进程的wait()类似——阻塞等待指定线程退出,并获取其返回值。

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

参数:

  • thread:要等待的线程ID
  • retval:二级指针,用于接收线程的返回值。如果不需要,传NULL

完整示例:

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

void *compute(void *arg) {
    int n = *(int *)arg;
    int *sum = malloc(sizeof(int));
    *sum = 0;
    for (int i = 1; i <= n; i++) {
        *sum += i;
    }
    pthread_exit((void *)sum);
}

int main() {
    pthread_t tid;
    int n = 100;

    pthread_create(&tid, NULL, compute, &n);

    void *retval;
    pthread_join(tid, &retval);

    printf("1到%d的和 = %d\n", n, *(int *)retval);
    free(retval);  // 别忘了释放内存
    return 0;
}

线程ID与进程ID

这里有个容易混淆的点。在Linux中,每个线程在内核里其实就是一个轻量级进程(LWP)。所以:

  • 进程ID(PID):由内核分配,标识一个进程。同一进程内的所有线程共享同一个PID。
  • 线程ID(TID):由内核分配,标识一个线程。每个线程有自己唯一的TID。
  • pthread_t:由pthread库维护的线程标识符,用于pthread API调用。

获取方式:

#include <pthread.h>
#include <sys/syscall.h>
#include <unistd.h>

// 获取pthread线程ID
pthread_t self = pthread_self();

// 获取内核线程ID(TID)
pid_t tid = syscall(SYS_gettid);

// 获取进程ID(PID)
pid_t pid = getpid();

我写了个小工具来观察这些ID:

#include <stdio.h>
#include <pthread.h>
#include <sys/syscall.h>
#include <unistd.h>

void *show_ids(void *arg) {
    printf("子线程: PID=%d, TID=%ld, pthread_t=%lu\n",
           getpid(), syscall(SYS_gettid), pthread_self());
    return NULL;
}

int main() {
    pthread_t tid;
    printf("主线程: PID=%d, TID=%ld, pthread_t=%lu\n",
           getpid(), syscall(SYS_gettid), pthread_self());

    pthread_create(&tid, NULL, show_ids, NULL);
    pthread_join(tid, NULL);
    return 0;
}

运行结果类似:

主线程: PID=12345, TID=12345, pthread_t=140736000000000
子线程: PID=12345, TID=12346, pthread_t=140735000000000

看到了吗?PID相同,TID不同。主线程的TID等于PID,因为它是进程中的第一个线程。

关键点:pthread_t是用户态的线程标识,TID是内核态的线程标识。pthread_t只在同一进程内有意义,不能跨进程使用。

知识体系总览

下面这张图总结了线程基础的核心概念和API关系:

线程基础核心知识体系 进程 (Process) 线程 (Thread) — 轻量级进程 创建开销小 通信效率高 资源占用少 pthread_create pthread_exit pthread_join PID (进程ID) TID (线程ID) pthread_t

避坑指南

最后分享几个我踩过的坑:

  • 线程栈大小——默认线程栈是8MB,如果递归调用太深或者局部变量太大,会栈溢出。可以用pthread_attr_setstacksize()调整。
  • 线程安全——多个线程同时读写同一变量,一定要加锁。我见过太多因为没加锁导致的数据竞争bug。
  • 僵尸线程——创建了线程但不join也不detach,线程退出后资源无法回收,造成内存泄漏。要么pthread_join,要么pthread_detach。
  • 信号处理——多线程程序中的信号处理很复杂,建议只在主线程中处理信号,其他线程用sigprocmask阻塞。

曾经我犯过一个低级错误:在子线程里调用了exit(),结果整个进程直接退出了,其他线程还没干完活。记住,exit()是进程级的,pthread_exit()才是线程级的。

好了,线程基础就讲到这里。这些概念和API是后续学习线程同步、线程池、并发编程的基石。建议你亲手敲一遍代码,看看PID和TID的区别,感受一下线程的创建开销。实践出真知嘛。

公众号:蓝海资料掘金营,微信deep3321