第七章:多线程并发服务器——pthread库入门、线程同步与实战

各位同学,欢迎来到第七章。从这一章开始,我们正式进入多线程的世界。说实话,单线程的服务器就像只有一个收银员的超市——高峰期排队排到门外去。我早年做第一个并发项目时,用的就是多进程,fork 了一堆子进程,结果内存开销大得吓人。后来换成多线程,才真正体会到什么叫「轻量级并发」。

好,咱们今天就把 pthread 库、互斥锁、条件变量,以及怎么用它们搭一个多线程服务器,一次性讲透。

7.1 pthread 库入门:创建与等待线程

pthread 是 POSIX 标准下的线程库,Linux 上直接用。说白了,线程就是轻量级进程,共享同一块地址空间。你想想看,多个线程能直接访问同一个全局变量,这比进程间通信(管道、共享内存)方便太多了。

先看一个最简单的例子:

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

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

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

    // 创建线程
    if (pthread_create(&tid, NULL, thread_func, &val) != 0) {
        perror("pthread_create");
        exit(1);
    }

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

这里有几个关键点:

  • pthread_create 的第四个参数是传给线程函数的参数,我习惯传结构体指针,这样能带多个数据。
  • pthread_join 会阻塞主线程,直到子线程退出。如果不 join,主线程一结束,整个进程就没了,子线程会被强制终止。
  • 线程函数的返回值是 void*,你可以用 pthread_join 的第二个参数拿到这个返回值。
小提示: 编译时记得加 -lpthread 链接线程库。我当年第一次编译忘了加,报了一堆 undefined reference,还以为是代码写错了……

7.2 线程同步:互斥锁(Mutex)

多线程共享全局变量,听起来很美,但坑也在这里。多个线程同时写一个变量,结果会怎样?我举个例子:两个线程同时执行 counter++,你以为加了两次,实际上可能只加了一次。为什么?因为 counter++ 不是原子操作——它分三步:读、加、写。两个线程的读写交错在一起,数据就乱了。

解决办法就是互斥锁。说白了,就是给共享资源加把锁,同一时间只有一个线程能碰它。

#include <pthread.h>

pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
int shared_counter = 0;

void* worker(void* arg) {
    for (int i = 0; i < 100000; i++) {
        pthread_mutex_lock(&lock);
        shared_counter++;
        pthread_mutex_unlock(&lock);
    }
    return NULL;
}

嗯,这里要注意:锁的粒度要适中。锁得太细(比如每条指令都加锁),性能下降;锁得太粗(整个函数加锁),又失去了并发的意义。我在项目中遇到过一种情况:一个线程持有锁后去调用外部接口,结果外部接口超时,锁一直被占着,其他线程全部卡死。后来我加了个超时机制,用 pthread_mutex_timedlock 解决了。

避坑指南: 千万不要忘记解锁!如果某个分支提前 return 了,锁没释放,其他线程就永远拿不到锁。我曾经因为这个 bug 排查了一整个下午……建议用 goto 统一出口,或者用 RAII 风格封装。

7.3 线程同步:条件变量(Condition Variable)

互斥锁解决了「互斥」的问题,但有时候我们需要「等待某个条件成立」。比如生产者-消费者模型:消费者要等队列里有数据才能取,如果队列为空,它应该休眠,而不是空转轮询。

条件变量就是干这个的。它让线程在某个条件不满足时挂起,等条件满足时被唤醒。

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
int data_ready = 0;

// 生产者
void* producer(void* arg) {
    // 生产数据...
    pthread_mutex_lock(&mutex);
    data_ready = 1;
    pthread_cond_signal(&cond);  // 唤醒一个等待的消费者
    pthread_mutex_unlock(&mutex);
    return NULL;
}

// 消费者
void* consumer(void* arg) {
    pthread_mutex_lock(&mutex);
    while (data_ready == 0) {
        pthread_cond_wait(&cond, &mutex);  // 自动释放锁并等待
    }
    // 消费数据...
    pthread_mutex_unlock(&mutex);
    return NULL;
}

这里有个经典陷阱:pthread_cond_wait 为什么要在 while 循环里?因为存在「虚假唤醒」——即使没有调用 pthread_cond_signal,线程也可能被唤醒。所以必须用 while 重新检查条件。我刚开始学的时候也踩过这个坑,少写了个 while,结果程序偶尔会崩溃,查了好久才发现。

7.4 多线程服务器实现

好了,理论讲完,咱们来实战。一个典型的多线程服务器长这样:

  1. 主线程监听 socket,accept 等待客户端连接。
  2. 每来一个客户端,创建一个新线程去处理。
  3. 子线程处理完业务后自动退出。

下面是一个简化版的实现:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <pthread.h>

#define PORT 8888
#define MAX_CLIENTS 100

void* handle_client(void* arg) {
    int client_fd = *(int*)arg;
    free(arg);  // 释放动态分配的内存

    char buffer[1024];
    int n = read(client_fd, buffer, sizeof(buffer) - 1);
    if (n > 0) {
        buffer[n] = '\0';
        printf("收到: %s\n", buffer);
        write(client_fd, "OK", 2);
    }

    close(client_fd);
    return NULL;
}

int main() {
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (server_fd < 0) {
        perror("socket");
        exit(1);
    }

    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    addr.sin_port = htons(PORT);

    if (bind(server_fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
        perror("bind");
        exit(1);
    }

    listen(server_fd, 10);
    printf("服务器启动,监听端口 %d\n", PORT);

    while (1) {
        struct sockaddr_in client_addr;
        socklen_t len = sizeof(client_addr);
        int* client_fd = malloc(sizeof(int));
        *client_fd = accept(server_fd, (struct sockaddr*)&client_addr, &len);
        if (*client_fd < 0) {
            perror("accept");
            free(client_fd);
            continue;
        }

        pthread_t tid;
        pthread_create(&tid, NULL, handle_client, client_fd);
        pthread_detach(tid);  // 分离线程,自动回收资源
    }

    close(server_fd);
    return 0;
}

这段代码有几个设计要点:

  • 我把 client_fdmalloc 分配在堆上,传给子线程。为什么?因为如果直接传栈上变量的地址,主线程下次循环就可能覆盖它。
  • 用了 pthread_detach,这样子线程结束后系统自动回收资源,主线程不用 pthread_join 一个个等。
  • 注意:这个版本没有做线程数量限制。如果同时有几千个客户端连接,系统会扛不住。实际项目中我会加一个线程池,或者用 sem_wait 控制并发数。
核心逻辑流程图:
主线程启动 socket() → bind() → listen() accept() 等待连接 创建子线程 pthread_detach() 子线程启动 read() 读取客户端请求 处理业务逻辑 write() 返回响应 close() 线程退出 传递 client_fd

这个图把主线程和子线程的职责分得很清楚。主线程只负责 accept 和创建线程,子线程负责具体的读写和业务处理。这种模式在业界叫「one thread per connection」,简单直接,适合中小型项目。

7.5 线程安全与常见陷阱

多线程编程,坑是真的多。我列几个最常见的:

陷阱 说明 解决方案
死锁 两个线程互相等待对方释放锁 固定锁的获取顺序,或使用 trylock
数据竞争 多个线程同时读写同一变量 加互斥锁,或使用原子操作
线程泄漏 创建了线程但没有 join 或 detach 统一使用 pthread_detach 或线程池
信号处理 多线程中信号的处理方式不同 指定信号处理线程,或屏蔽信号

嗯,说到死锁,我记得有一次写一个双缓冲区的代码,两个线程各持一把锁,然后互相等对方释放。程序跑起来直接卡死,连 gdb 都 attach 不上去。后来我强制 core dump 才看到两个线程都在 __lll_lock_wait 里。从那以后,我写多线程代码一定会画锁的依赖图,确保没有循环等待。

个人习惯: 我一般会在每个锁的加锁和解锁处加日志,调试阶段打开,上线后关掉。这样一旦出现死锁,看日志就能知道最后一把锁是谁拿的。

好了,这一章的内容就到这里。多线程并发服务器是网络编程的必修课,pthread 库虽然看起来 API 不多,但组合起来能玩出很多花样。下一章我们会深入探讨 I/O 多路复用——select、poll、epoll,那才是真正的高并发利器。


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