3065 字
8 分钟
TCP&IP网络编程学习札记_11
2026-09-21
浏览量 11 · 访客 1

多线程服务器端的实现#

18.1 理解线程的概念#

多进程服务器可以同时处理多个客户端,但创建进程、切换进程以及进程间交换数据都需要额外成本。线程保留了多条执行流的优点,同时让这些执行流共享同一进程的大部分资源,因此也被称为“轻量级进程”。

引入线程的原因#

进程的内存空间由数据区、堆和栈组成。每创建一个进程,操作系统都要为它准备相互独立的内存结构:

替换文本

进程切换时,操作系统需要保存当前执行状态,再恢复另一个进程的状态,这一过程称为上下文切换Context SwitchingContext\ Switching)。进程拥有独立的数据区和堆,创建、切换与通信的成本都相对较高。

若目的只是获得多条代码执行流,没有必要复制整个内存空间。线程只保留各自独立的栈,共享进程的数据区和堆:

替换文本

这样做带来两个直接结果:

  • 创建线程和切换线程通常比进程更快。
  • 线程可以直接通过共享内存交换数据,不需要额外的IPCIPC机制。

但共享也是多线程最需要谨慎处理的部分。多个线程能够访问同一变量,意味着它们也可能同时修改同一变量,并破坏数据的一致性。

进程与线程的关系#

进程是操作系统中构成独立执行流的单位,线程则是在进程内部构成独立执行流的单位。一个进程至少包含执行mainmain函数的主线程,也可以继续创建其他线程:

替换文本

线程不是脱离进程单独存在的资源。进程结束时,其内部线程也会一同终止;同一进程中的线程共享数据区、堆、打开的文件和套接字,但各自拥有独立的栈与执行位置。

18.2 线程的创建及运行#

POSIXPOSIX线程接口通常简称为PthreadPthread。它不仅适用于LinuxLinux,也适用于大多数遵循POSIXPOSIX规范的UNIXUNIX系列操作系统。使用相关函数时需要包含pthread.hpthread.h,编译时还要连接线程库:

gcc thread.c -D_REENTRANT -o thread -lpthread

D_REENTRANT-D\_REENTRANT用于启用可重入、线程安全的接口定义,lpthread-lpthread用于连接PthreadPthread库。

创建线程#

#include <pthread.h>
int pthread_create(pthread_t *thread,
const pthread_attr_t *attr,
void *(*start_routine)(void *),
void *arg);
//成功时返回0,失败时返回其他值。

threadthread用于保存新线程的IDIDattrattr用于指定线程属性,传入NULLNULL表示使用默认属性。start_routinestart\_routine是线程开始执行的函数,argarg则是传给该函数的参数。线程函数的参数和返回值都使用voidvoid *,因此可以传递不同类型的数据地址。

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);

调用pthread_createpthread\_create后,主线程与新线程会沿不同执行流继续运行,具体调度顺序由操作系统决定:

替换文本

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

替换文本

因此,不能依靠估算执行时间后调用sleepsleep来等待线程。线程运行时间受系统调度与负载影响,固定等待时间过短会提前终止,过长则会造成无意义的停顿。

等待线程结束#

#include <pthread.h>
int pthread_join(pthread_t thread, void **status);
//成功时返回0,失败时返回其他值。

pthread_joinpthread\_join会阻塞调用它的线程,直到指定线程结束。若statusstatus不为NULLNULL,还可以取得线程函数的返回值:

void *thread_return;
pthread_create(&thread_id, NULL,
thread_main, (void *)&count);
pthread_join(thread_id, &thread_return);

替换文本

这种等待是由线程状态驱动的,不依赖对运行时间的猜测。pthread_joinpthread\_join还会回收线程终止后保留的资源,因此适合既要等待结果、又要完成线程清理的场景。

线程安全函数#

函数中若存在多个线程同时执行时会引发问题的代码,该区域就是临界区。根据能否被多个线程安全地同时调用,函数可以分为:

  • 线程安全函数Threadsafe functionThread-safe\ function):多个线程同时调用也能保持正确结果。
  • 非线程安全函数Threadunsafe functionThread-unsafe\ function):同时调用时可能相互干扰。

线程安全函数不代表内部没有临界区,而是函数已经通过同步、局部存储等方式保护了这些区域。部分LinuxLinux函数会提供名称以_r\_r结尾的可重入版本,例如gethostbyname_rgethostbyname\_r

工作线程模型#

主线程负责分配任务,其他线程负责执行具体工作,这种结构称为工作线程模型Worker Thread ModelWorker\ Thread\ Model)。例如,可以让两个线程分别计算1155661010的和,再由主线程等待并输出最终结果:

替换文本

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);

线程能够直接累加全局变量sumsum,正是因为它们共享数据区。不过,“能够访问”不等于“可以无条件同时修改”。这个简单示例的结果可能正确,但共享变量的读写仍然隐藏着竞态。

18.3 线程存在的问题和临界区#

假设两个线程都准备把共享变量numnum11,初始值为9999

替换文本

看似连续的操作并不原子#

num+=1num += 1在源代码中只有一条语句,但处理器执行时至少要经历“读取、计算、写回”三个步骤。理想情况下,线程11完整写回100100后,线程22再读取并写回101101

替换文本

替换文本

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

替换文本

线程22同样读取旧值9999,完成运算后将100100写入numnum

替换文本

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

替换文本

这种结果依赖线程执行时序的错误称为竞态条件Race ConditionRace\ Condition)。它不要求两个线程在物理意义上同一时刻运行;只要两个操作的执行步骤交错,就可能出现。多核处理器只会让真正并行访问的机会更多。

临界区的位置#

临界区不是共享变量numnum本身,而是访问它并可能产生冲突的代码:

void *thread_inc(void *arg)
{
num += 1; //临界区
return NULL;
}
void *thread_des(void *arg)
{
num -= 1; //同一组临界区
return NULL;
}

临界区也不一定只由同一函数中的同一段代码构成。一个线程执行num+=1num += 1,另一个线程执行num=1num -= 1,虽然语句与函数不同,但它们修改同一共享状态,仍然构成相互关联的临界区。

要避免竞态,需要保证某个线程进入临界区后,其他线程暂时不能进入相关临界区,直到前一个线程完成操作。这就是线程同步的一项核心任务。

18.4 线程同步#

线程同步主要解决两类问题:

  1. 防止多个线程同时访问同一共享资源。
  2. 按照规定顺序执行多个线程的操作。

互斥量更适合限制并发访问,信号量既可以限制访问数量,也常用于安排线程执行顺序。

使用互斥量保护临界区#

互斥量(MutexMutex)可以理解为一把锁。线程进入临界区前加锁,退出后解锁;若锁已被其他线程持有,当前线程会等待。

#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);

pthread_mutex_lockpthread\_mutex\_lock返回后,当前线程已经取得锁;若锁被占用,它会阻塞到锁被释放。无论临界区正常结束还是发生错误,都必须保证执行pthread_mutex_unlockpthread\_mutex\_unlock。遗漏解锁会让其他线程一直等待,形成死锁DeadlockDeadlock)。

确定加锁范围#

锁的范围会同时影响正确性与性能。只包围一条共享变量操作,可以缩短其他线程的等待时间,但循环中反复加锁、解锁会产生大量同步开销:

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);

后一种方式更省锁操作,却会长时间阻止其他线程访问numnum。临界区既不能小到遗漏需要共同保护的状态,也不应无故扩大。实际范围应根据共享数据的一致性要求、锁竞争和操作耗时决定。

使用信号量控制执行顺序#

信号量(SemaphoreSemaphore)内部保存一个非负整数。sem_waitsem\_wait尝试将其减11;若当前值为00,调用线程会阻塞。sem_postsem\_post将其加11,并可唤醒等待中的线程。

#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,失败时返回其他值。

同一进程内的线程使用信号量时,psharedpshared通常传入00valuevalue指定初始值。只在0011之间变化的信号量称为二进制信号量

例如,一个线程读取用户输入,另一个线程负责累加。需要保证“写入新值”和“取走当前值”严格交替,可以使用两个信号量:

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);

sem_readysem\_ready防止累加线程在数据写入前读取旧值,sem_consumedsem\_consumed防止输入线程在旧值取走前覆盖它。这里同步的重点不是互斥访问某段代码,而是建立两个线程之间的先后关系。

18.5 线程的销毁和多线程并发服务器#

回收已经终止的线程#

线程函数返回后,线程虽然停止运行,但部分管理资源仍可能保留。应通过以下方式之一完成回收:

  • 调用pthread_joinpthread\_join等待线程结束并回收资源。
  • 调用pthread_detachpthread\_detach把线程设为分离状态,使其结束后自动回收资源。
#include <pthread.h>
int pthread_detach(pthread_t thread);
//成功时返回0,失败时返回其他值。

pthread_detachpthread\_detach不会终止线程,也不会等待线程结束。它只改变资源回收方式,因此适合服务器中创建后无需取得返回值的连接处理线程。线程一旦被分离,就不能再对它调用pthread_joinpthread\_join

多线程聊天服务器的结构#

多线程并发服务器可以为每个客户端创建一个处理线程。主线程负责接受连接,工作线程负责读取对应客户端的消息,再把消息转发给所有已连接客户端:

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);
}

连接列表clnt_socksclnt\_socks和客户端数量clnt_cntclnt\_cnt会被主线程与多个工作线程共同访问,因此添加、删除和遍历客户端时都需要使用同一个互斥量保护:

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);
}

保护对象不是某个孤立变量,而是彼此共同描述连接状态的一组数据。若一个线程删除数组元素时,另一个线程正根据旧的clnt_cntclnt\_cnt遍历数组,就可能访问无效套接字;因此相关更新必须作为一个整体保持一致。

客户端也可以分别创建发送线程和接收线程。发送线程等待键盘输入并写入套接字,接收线程持续读取服务器消息。这样,即使接收操作正在等待网络数据,用户仍可继续输入;两个方向不必在同一条执行流中交替阻塞。

多线程模型让服务器的处理结构更加直观,但它并不会自动带来正确性。线程共享的连接表、计数器和业务状态都需要先明确所有权,再确定临界区与同步方式。只有在共享范围清晰的前提下,线程带来的低切换成本和直接数据交换才会真正成为优势。

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

TCP&IP网络编程学习札记_11
https://mkrari.cn/posts/tcp_ip_learn_11/
作者
Mkrari
发布于
2026-09-21
许可协议
CC BY-NC-SA 4.0