
多线程服务器端的实现
18.1 理解线程的概念
多进程服务器可以同时处理多个客户端,但创建进程、切换进程以及进程间交换数据都需要额外成本。线程保留了多条执行流的优点,同时让这些执行流共享同一进程的大部分资源,因此也被称为“轻量级进程”。
引入线程的原因
进程的内存空间由数据区、堆和栈组成。每创建一个进程,操作系统都要为它准备相互独立的内存结构:

进程切换时,操作系统需要保存当前执行状态,再恢复另一个进程的状态,这一过程称为上下文切换()。进程拥有独立的数据区和堆,创建、切换与通信的成本都相对较高。
若目的只是获得多条代码执行流,没有必要复制整个内存空间。线程只保留各自独立的栈,共享进程的数据区和堆:

这样做带来两个直接结果:
- 创建线程和切换线程通常比进程更快。
- 线程可以直接通过共享内存交换数据,不需要额外的机制。
但共享也是多线程最需要谨慎处理的部分。多个线程能够访问同一变量,意味着它们也可能同时修改同一变量,并破坏数据的一致性。
进程与线程的关系
进程是操作系统中构成独立执行流的单位,线程则是在进程内部构成独立执行流的单位。一个进程至少包含执行函数的主线程,也可以继续创建其他线程:

线程不是脱离进程单独存在的资源。进程结束时,其内部线程也会一同终止;同一进程中的线程共享数据区、堆、打开的文件和套接字,但各自拥有独立的栈与执行位置。
18.2 线程的创建及运行
线程接口通常简称为。它不仅适用于,也适用于大多数遵循规范的系列操作系统。使用相关函数时需要包含,编译时还要连接线程库:
gcc thread.c -D_REENTRANT -o thread -lpthread用于启用可重入、线程安全的接口定义,用于连接库。
创建线程
#include <pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void *), void *arg);//成功时返回0,失败时返回其他值。用于保存新线程的,用于指定线程属性,传入表示使用默认属性。是线程开始执行的函数,则是传给该函数的参数。线程函数的参数和返回值都使用,因此可以传递不同类型的数据地址。
void *thread_main(void *arg){ int count = *((int *)arg);
while (count--) puts("running thread");
return NULL;}
pthread_t thread_id;int count = 5;
pthread_create(&thread_id, NULL, thread_main, (void *)&count);调用后,主线程与新线程会沿不同执行流继续运行,具体调度顺序由操作系统决定:

若主线程通过结束函数,整个进程会终止,新线程也会被强制结束:

因此,不能依靠估算执行时间后调用来等待线程。线程运行时间受系统调度与负载影响,固定等待时间过短会提前终止,过长则会造成无意义的停顿。
等待线程结束
#include <pthread.h>
int pthread_join(pthread_t thread, void **status);//成功时返回0,失败时返回其他值。会阻塞调用它的线程,直到指定线程结束。若不为,还可以取得线程函数的返回值:
void *thread_return;
pthread_create(&thread_id, NULL, thread_main, (void *)&count);pthread_join(thread_id, &thread_return);
这种等待是由线程状态驱动的,不依赖对运行时间的猜测。还会回收线程终止后保留的资源,因此适合既要等待结果、又要完成线程清理的场景。
线程安全函数
函数中若存在多个线程同时执行时会引发问题的代码,该区域就是临界区。根据能否被多个线程安全地同时调用,函数可以分为:
- 线程安全函数():多个线程同时调用也能保持正确结果。
- 非线程安全函数():同时调用时可能相互干扰。
线程安全函数不代表内部没有临界区,而是函数已经通过同步、局部存储等方式保护了这些区域。部分函数会提供名称以结尾的可重入版本,例如。
工作线程模型
主线程负责分配任务,其他线程负责执行具体工作,这种结构称为工作线程模型()。例如,可以让两个线程分别计算至与至的和,再由主线程等待并输出最终结果:

pthread_create(&id1, NULL, thread_summation, range1);pthread_create(&id2, NULL, thread_summation, range2);
pthread_join(id1, NULL);pthread_join(id2, NULL);
printf("result: %d\n", sum);线程能够直接累加全局变量,正是因为它们共享数据区。不过,“能够访问”不等于“可以无条件同时修改”。这个简单示例的结果可能正确,但共享变量的读写仍然隐藏着竞态。
18.3 线程存在的问题和临界区
假设两个线程都准备把共享变量加,初始值为:

看似连续的操作并不原子
在源代码中只有一条语句,但处理器执行时至少要经历“读取、计算、写回”三个步骤。理想情况下,线程完整写回后,线程再读取并写回:


问题在于,线程可能在任意指令之间被切换。线程读取并算出,还没有写回内存时,执行权就可能转交给线程:

线程同样读取旧值,完成运算后将写入:

随后线程恢复运行,又把自己先前算出的写回。两个线程各执行了一次加法,最终结果却只增加了:

这种结果依赖线程执行时序的错误称为竞态条件()。它不要求两个线程在物理意义上同一时刻运行;只要两个操作的执行步骤交错,就可能出现。多核处理器只会让真正并行访问的机会更多。
临界区的位置
临界区不是共享变量本身,而是访问它并可能产生冲突的代码:
void *thread_inc(void *arg){ num += 1; //临界区 return NULL;}
void *thread_des(void *arg){ num -= 1; //同一组临界区 return NULL;}临界区也不一定只由同一函数中的同一段代码构成。一个线程执行,另一个线程执行,虽然语句与函数不同,但它们修改同一共享状态,仍然构成相互关联的临界区。
要避免竞态,需要保证某个线程进入临界区后,其他线程暂时不能进入相关临界区,直到前一个线程完成操作。这就是线程同步的一项核心任务。
18.4 线程同步
线程同步主要解决两类问题:
- 防止多个线程同时访问同一共享资源。
- 按照规定顺序执行多个线程的操作。
互斥量更适合限制并发访问,信号量既可以限制访问数量,也常用于安排线程执行顺序。
使用互斥量保护临界区
互斥量()可以理解为一把锁。线程进入临界区前加锁,退出后解锁;若锁已被其他线程持有,当前线程会等待。
#include <pthread.h>
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);int pthread_mutex_destroy(pthread_mutex_t *mutex);int pthread_mutex_lock(pthread_mutex_t *mutex);int pthread_mutex_unlock(pthread_mutex_t *mutex);//成功时返回0,失败时返回其他值。pthread_mutex_t mutex;
pthread_mutex_init(&mutex, NULL);
pthread_mutex_lock(&mutex);num += 1;pthread_mutex_unlock(&mutex);
pthread_mutex_destroy(&mutex);返回后,当前线程已经取得锁;若锁被占用,它会阻塞到锁被释放。无论临界区正常结束还是发生错误,都必须保证执行。遗漏解锁会让其他线程一直等待,形成死锁()。
确定加锁范围
锁的范围会同时影响正确性与性能。只包围一条共享变量操作,可以缩短其他线程的等待时间,但循环中反复加锁、解锁会产生大量同步开销:
for (i = 0; i < 50000000; i++){ pthread_mutex_lock(&mutex); num += 1; pthread_mutex_unlock(&mutex);}也可以把整个循环放进临界区,减少锁操作次数:
pthread_mutex_lock(&mutex);
for (i = 0; i < 50000000; i++) num += 1;
pthread_mutex_unlock(&mutex);后一种方式更省锁操作,却会长时间阻止其他线程访问。临界区既不能小到遗漏需要共同保护的状态,也不应无故扩大。实际范围应根据共享数据的一致性要求、锁竞争和操作耗时决定。
使用信号量控制执行顺序
信号量()内部保存一个非负整数。尝试将其减;若当前值为,调用线程会阻塞。将其加,并可唤醒等待中的线程。
#include <semaphore.h>
int sem_init(sem_t *sem, int pshared, unsigned int value);int sem_destroy(sem_t *sem);int sem_wait(sem_t *sem);int sem_post(sem_t *sem);//成功时返回0,失败时返回其他值。同一进程内的线程使用信号量时,通常传入,指定初始值。只在和之间变化的信号量称为二进制信号量。
例如,一个线程读取用户输入,另一个线程负责累加。需要保证“写入新值”和“取走当前值”严格交替,可以使用两个信号量:
sem_t sem_ready;sem_t sem_consumed;
sem_init(&sem_ready, 0, 0);sem_init(&sem_consumed, 0, 1);输入线程等待旧数据已被取走,再写入新值:
sem_wait(&sem_consumed);scanf("%d", &num);sem_post(&sem_ready);累加线程等待新数据就绪,读取后允许下一次输入:
sem_wait(&sem_ready);sum += num;sem_post(&sem_consumed);防止累加线程在数据写入前读取旧值,防止输入线程在旧值取走前覆盖它。这里同步的重点不是互斥访问某段代码,而是建立两个线程之间的先后关系。
18.5 线程的销毁和多线程并发服务器
回收已经终止的线程
线程函数返回后,线程虽然停止运行,但部分管理资源仍可能保留。应通过以下方式之一完成回收:
- 调用等待线程结束并回收资源。
- 调用把线程设为分离状态,使其结束后自动回收资源。
#include <pthread.h>
int pthread_detach(pthread_t thread);//成功时返回0,失败时返回其他值。不会终止线程,也不会等待线程结束。它只改变资源回收方式,因此适合服务器中创建后无需取得返回值的连接处理线程。线程一旦被分离,就不能再对它调用。
多线程聊天服务器的结构
多线程并发服务器可以为每个客户端创建一个处理线程。主线程负责接受连接,工作线程负责读取对应客户端的消息,再把消息转发给所有已连接客户端:
while (1){ clnt_sock = accept(serv_sock, (struct sockaddr *)&clnt_adr, &clnt_adr_sz);
pthread_mutex_lock(&mutex); clnt_socks[clnt_cnt++] = clnt_sock; pthread_mutex_unlock(&mutex);
pthread_create(&thread_id, NULL, handle_clnt, (void *)&clnt_sock); pthread_detach(thread_id);}连接列表和客户端数量会被主线程与多个工作线程共同访问,因此添加、删除和遍历客户端时都需要使用同一个互斥量保护:
void send_msg(char *msg, int len){ int i;
pthread_mutex_lock(&mutex);
for (i = 0; i < clnt_cnt; i++) write(clnt_socks[i], msg, len);
pthread_mutex_unlock(&mutex);}保护对象不是某个孤立变量,而是彼此共同描述连接状态的一组数据。若一个线程删除数组元素时,另一个线程正根据旧的遍历数组,就可能访问无效套接字;因此相关更新必须作为一个整体保持一致。
客户端也可以分别创建发送线程和接收线程。发送线程等待键盘输入并写入套接字,接收线程持续读取服务器消息。这样,即使接收操作正在等待网络数据,用户仍可继续输入;两个方向不必在同一条执行流中交替阻塞。
多线程模型让服务器的处理结构更加直观,但它并不会自动带来正确性。线程共享的连接表、计数器和业务状态都需要先明确所有权,再确定临界区与同步方式。只有在共享范围清晰的前提下,线程带来的低切换成本和直接数据交换才会真正成为优势。
