《TCP/IP 网络编程》第 18 章——多线程服务器端的实现(学习笔记)

代码链接

第 18 章 多线程服务器端的实现

18.1 理解线程的概念

18.1.1 引入线程的背景

第 10 章介绍了多进程服务器端的实现方法。多进程模型与 select 或 epoll 相比的确有自身的优点,但同时也有问题。如前所述,创建进程(复制)的工作本身会给操作系统带来相当沉重的负担。而且,每个进程具有独立的内存空间,所以进程间通信的实现难度也会随之提高。换言之,多进程模型的缺点可概括如下。

  • 创建进程的过程会带来一定的开销
  • 为了完成进程间数据交换,需要特殊的 IPC 技术

但相比于下面的缺点,上述 2 个缺点不算什么。

  • 每秒少则数十次、多则数千次的『上下文切换』是创建进程时最大的开销。

上下文切换的概念:运行程序前需要将相应进程信息读入内存,如果运行进程 A 后需要紧接着运行进程 B,就应该将进程 A 相关信息移出内存,并读入进程 B 相关信息。即使优化加快切换速度,也存在一定的局限。

为了保持多进程的优点,同时在一定程度上克服其缺点,人们引入了线程(Thread)。这是为了将进程的各种劣势降至最低限度而设计的一种『轻量级进程』。线程相比进程具有如下优点。

  • 线程的创建和上下文切换比进程的创建和上下文切换更快
  • 线程间交换数据时无需特殊技术

18.1.2 线程和进程的差异

线程是为了解决如下困惑登场的:为了得到多条代码执行流而复制整个内存区域的负担太重了。

每个进程的内存空间都由保存全局变量的『数据区』、向 malloc 等函数的动态分配提供空间的堆(Heap)、函数运行时使用的栈(Stack)构成。每个进程都拥有这种独立空间。

但如果以获得多个代码执行流为主要目的,则不需要完全分离内存结构,只需分离栈区域。通过这种方式可以获得如下优势。

  • 上下文切换时不需要切换数据区和堆
  • 可以利用数据区和堆交换数据

实际上这就是线程。线程为了保持多条代码执行流而隔开了栈区域。

多个线程将共享数据区和堆。为了保持这种结构,线程将在进程内创建并运行。也就是说,进程和线程可以定义为如下形式。

  • 进程:在操作系统构成单独执行流的单位。
  • 线程:在进程构成单独执行流的单位。

如果说进程在操作系统内部生成多个执行流,那么线程就在同一进程内部创建多条执行流。

18.2 线程创建及运行

下面要介绍的线程创建方法是以 POSIX 标准为依据的。因此,它不仅适用于 Linux,也适用于大部分 UNIX 系列的操作系统。

18.2.1 线程的创建和执行流程

线程具有单独的执行流,因此需要单独定义线程的 main 函数,还需要请求操作系统在单独的执行流中执行该函数,完成该功能的函数如下。

#include <pthread.h>

int pthread_create(
    pthread_t *restrict thread, const pthread_attr_t *restrict attr,
    void *(*start_routine)(void *), void *restrict arg
);
/*
成功时返回 0,失败时返回其他值
thread:保存新创建线程 ID 的变量地址值。线程与进程相同,也需要用于区分不同线程的 ID
attr:用于传递线程属性的参数,传递 NULL 时,创建默认属性的线程
start_routine:相当于线程 main 函数的、在单独执行流中执行的函数地址值(函数指针)
arg:通过第三个参数传递调用函数时包含传递参数信息的变量地址值
*/

下面通过示例了解该函数功能,代码参考 thread1.c 文件。

运行结果

wzy@wzypc:~/TCP-IP-NetworkNote/chapter-18$ # 线程相关代码在编译时需要添加 -lpthread 选项声明以连接到线程库,这样才能调用头文件 pthread.h 中声明的函数
wzy@wzypc:~/TCP-IP-NetworkNote/chapter-18$ gcc thread1.c -o tr1.exe -lpthread
wzy@wzypc:~/TCP-IP-NetworkNote/chapter-18$ ./tr1.exe
running thread
running thread
running thread
running thread
running thread
end of main
wzy@wzypc:~/TCP-IP-NetworkNote/chapter-18$ 

main 函数中 sleep 函数调用是为了避免线程结束前结束进程。下面介绍 main 函数中不用 sleep 的方法。

pthread_join

#include <pthread.h>
int pthread_join(pthread_t thread, void **status);
/*
成功时返回 0,失败时返回其他值
thread:该参数值 ID 的线程终止后才会从该函数返回
status:保存线程的 main 函数返回值的指针的变量地址值
*/

简言之,调用该函数的进程(或线程)将进入等待状态,直到第一个参数为 ID 的线程终止。而且可以得到线程的 main 函数返回值。

下面通过示例了解该函数功能,代码参考 thread2.c 文件

运行结果

wzy@wzypc:~/TCP-IP-NetworkNote/chapter-18$ gcc thread2.c -o tr2.exe -lpthread 
wzy@wzypc:~/TCP-IP-NetworkNote/chapter-18$ ./tr2.exe
running thread
running thread
running thread
running thread
running thread
Thread return message: Hello, I'am thread~ 
 
wzy@wzypc:~/TCP-IP-NetworkNote/chapter-18$ 

18.2.2 可在临界区内调用的函数

根据临界区是否引起问题,函数可分为以下 2 类。

  • 线程安全函数
  • 非线程安全函数

线程安全函数被多个线程同时调用时也不会引发问题。反之,非线程安全函数被同时调用时会引发问题。线程安全的函数同样可能存在临界区,只是可以通过一些措施避免问题。

幸运的是,大多数标准函数都是线程安全的函数。更幸运的是,我们不用自己区分线程安全的函数和非线程安全的函数。因为这些平台在定义非线程安全函数的同时,提供了具有相同功能的线程安全的函数.

比如第 8 章的如下函数就是非线程安全函数。

struct hostent *gethostbyname(const char *hostname);

同时提供线程安全的同一功能的函数。

struct hostent *gethostbyname_r(
    const char *name, struct hostent *result,
    char *buffer, int intbuflen, int *h_errnop
);

线程安全函数的名称后缀通常为 _r。代码改为调用 _r 会给程序员带来负担,可以通过如下方法自动将 gethostbyname 函数调用改为 gethostbyname_r 函数调用。

声明头文件前定义 _REENTRANT 宏

无需特意更改源代码,可以在编译时通过添加 -D_REENTRANT 选项定义宏。

gcc -D_REENTRANT mythread.c -o mthread -lpthread

下面编译线程相关代码时均默认添加 -D-REENTRANT 选项。

18.2.3 工作(Worker)线程模型

下面介绍的示例将计算 1 到 10 的和。通过创建 2 个线程,其中一个线程计算 1 到 5 的和,另一个线程计算 6 到 10 的和,main 函数只负责输出运算结果。这种方式的编程模型称为『工作线程模型』。

代码参考 thread3.c 文件

运行结果

wzy@wzypc:~/TCP-IP-NetworkNote/chapter-18$ gcc thread3.c -D_REENTRANT -o tr3.exe -lpthread
wzy@wzypc:~/TCP-IP-NetworkNote/chapter-18$ ./tr3.exe
result: 55 
wzy@wzypc:~/TCP-IP-NetworkNote/chapter-18$ 

虽然结果正确,但示例本身存在问题,此处存在临界区相关问题。下面再介绍另一示例,与上述示例相似,只是增加了发生临界区相关错误的可能性。代码参考 thread4.c 文件。

运行结果

wzy@wzypc:~/TCP-IP-NetworkNote/chapter-18$ gcc thread4.c -D_REENTRANT -o tr4.exe -lpthread
wzy@wzypc:~/TCP-IP-NetworkNote/chapter-18$ ./tr4.exe
sizeof long long: 8 
result: -2652792 
wzy@wzypc:~/TCP-IP-NetworkNote/chapter-18$ ./tr4.exe
sizeof long long: 8 
result: -21590734 
wzy@wzypc:~/TCP-IP-NetworkNote/chapter-18$ 

运行结果并不是 0,而且每次运行的结果均不同。虽然其原因尚不得而知,但可以肯定的是,这对于线程的应用是个大问题。

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

18.3.1 多个线程访问同一变量的问题

示例 thread4.c 问题是:两个线程正在同时访问全局变量 num。

任何内存空间,只要被同时访问,都可能发生问题。

Q:不是说线程会分时使用 CPU 吗?那应该不会出现同时访问变量的情况啊。

A:一个操作没有完成,这时候执行的线程切换,会导致问题。如两个线程对一个变量 99 进行 +1 操作,得到的结果可能是 100,也可能是 101。

因此线程访问变量 num 时应该阻止其他线程访问,直到线程 1 完成运算。这就是『同步』。

18.3.2 临界区位置

临界区定义为如下这种形式。

函数内同时运行多个线程时引起问题的多条语句构成的代码块

示例 thread4.c 中寻找临界区。全局变量 num 是否应该视为临界区?不是,因为它不是引起问题的语句。

下面观察 thread4.c 中的 2 个 main 函数。

void *thread_inc(void *arg) {
    int i;
    for(i = 0; i < 50000000; i++)
        num += 1; // 临界区
    return NULL;
}

void *thread_des(void *arg) {
    int i;
    for(i = 0; i < 50000000; i++)
        num -= 1; // 临界区
    return NULL;
}

由代码注释可知,临界区并非 num 本身,而是访问 num 的 2 条语句。这 2 条语句可能由多个线程同时运行,也是引起问题的直接原因。产生的问题可以整理为如下 3 种情况。

  • 2 个线程同时执行 thread_inc 函数
  • 2 个线程同时执行 thread_des 函数
  • 2 个线程分别执行 thread_inc 和 thread_des 函数

观察最后一点,说明 2 条不同语句由不同线程同时执行时,也有可能构成临界区。前提是这 2 条语句访问同一内存空间。

18.4 线程同步

前面探讨了线程中存在的问题,接下来就要讨论解决方法——线程同步。

18.4.1 同步的两面性

线程同步用于解决线程访问顺序引发的问题。需要同步的情况可以从如下两方面考虑。

  • 同时访问同一内存空间时发生的情况
  • 需要指定访问同一内存空间的线程执行顺序的情况

情况一已经解释过,讨论情况二。这是『控制线程执行顺序』的相关内容。假设有 A、B 两个线程,线程 A 负责向指定内存写入数据,线程 B 负责取走该数据。这种情况下,线程 A 首先应该访问约定的内存空间并保存数据。万一线程 B 先访问并取走数据,将导致错误结果。像这种需要控制执行顺序的情况也需要使用同步技术。

18.4.2 互斥量

互斥量也称互斥锁,可以用来保证一个对象在某一时刻只有一个线程访问。下面介绍互斥量的创建及销毁函数。

#include <pthread.h>
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);
int pthread_mutex_destroy(pthread_mutex_t *mutex);
/*
成功时返回 0,失败时返回其他值
mutex:创建互斥量时传递保存互斥量的变量地址值,销毁时传递需要销毁的互斥量地址值
attr:传递即将创建的互斥量属性,没有特别需要指定的属性时传递 NULL
*/

从上述函数声明中也可以看出,为了创建相当于锁系统的互斥量,需要声明如下 pthread_mutex_t 型变量:

pthread_mutex_t mutex;

该变量的地址将传递给 pthread_mutex_init 函数,用来保存操作系统创建的互斥量(锁系统)。调用 pthread_mutex_destroy 函数时同样需要该信息。如果不需要配置特殊的互斥量属性,则向第二个参数传递 NULL 时,可以利用 PTHREAD_MUTEX_INITIALIZER 宏进行如下声明:

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

但推荐各位尽可能使用 pthread_mutex_init 函数进行初始化,因为通过宏进行初始化时很难发现发生的错误。

接下来介绍利用互斥量锁住或释放临界区时使用的函数。

#include <pthread.h>
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
// 成功时返回 0,失败时返回其他值

函数名本身含有 lock、unlock 等词汇,很容易理解其含义。进入临界区前调用的函数就是 pthread_mutex_lock。调用该函数时,发现有其他线程已进入临界区,则 pthread_mutex_lock 函数不会返回,直到里面的线程调用 pthread_mutex_unlock 函数退出临界区为止。也就是说,其他线程让出临界区之前,当前线程一直处于阻塞状态。

pthread_mutex_lock(&mutex);
//临界区开始
//...
//临界区结束
pthread_mutex_unlock(&mutex);

接下来利用互斥量解决示例 thread4.c 中遇到的问题,代码参考 mutex.c 文件。

运行结果

wzy@wzypc:~/TCP-IP-NetworkNote/chapter-18$ gcc mutex.c -D_REENTRANT -o mutex.exe -lpthread
wzy@wzypc:~/TCP-IP-NetworkNote/chapter-18$ ./mutex.exe
result: 0 
wzy@wzypc:~/TCP-IP-NetworkNote/chapter-18$ 

可以看出线程已经同步。

18.4.3 信号量

(我的理解)信号量,一个计数器锁,P 操作使信号量 -1,V 操作使信号量 +1,如果信号量为 0,则阻塞。

下面给出信号量创建及销毁方法。

#include <semaphore.h>
int sem_init(sem_t *sem, int pshared, unsigned int value);
int sem_destroy(sem_t *sem);
/*
成功时返回 0,失败时返回其他值
sem:创建信号量时传递保存信号量的变量地址值,销毁时传递需要销毁的信号量变量地址值
pshared:传递其他值时,创建可由多个进程共享的信号量;传递 0 时,创建只允许 1 个进程内部使用的信号量。我们需要完成同一进程内的线程同步,故传递 0
value:指定新创建的信号量初始值
*/

接下来介绍进行信号量加减一操作的函数。

#include <semaphore.h>
int sem_post(sem_t *sem);
int sem_wait(sem_t *sem);
/*
成功时返回 0,失败时返回其他值
sem:传递保存信号量读取值的变量地址值,传递给 sem_post 时信号量增 1,传递给 sem_wait 时信号量减 1
*/

调用 sem_init 函数时,操作系统将创建信号量对象,此对象中记录着『信号量值』整数。该值在调用 sem_post 函数时增 1,调用 sem_wait 函数时减 1。但信号量的值不能小于 0。在信号量为 0 时调用 sem_wait 函数将阻塞。

假设信号量的初始值为 1,可以通过如下形式同步临界区。

sem_wait(&sem); // 信号量变为0...
// 临界区的开始
// ......
// 临界区的结束
sem_post(&sem); // 信号量变为1...

接下来给出信号量相关示例,即将介绍的示例并非关于同步访问的同步,而是关于控制访问顺序的同步。该示例的场景如下:

线程 A 从用户输入得到值后存入全局变量 num,此时线程 B 将取走该值并累加。该过程共进行 5 次,完成后输出总和并退出程序。

代码参考 semaphore.c 文件

运行结果

wzy@wzypc:~/TCP-IP-NetworkNote/chapter-18$ gcc semaphore.c -D_REENTRANT -o sema.exe -lpthread
wzy@wzypc:~/TCP-IP-NetworkNote/chapter-18$ ./sema.exe
Input num: 1
Input num: 2
Input num: 3
Input num: 4
Input num: 5
Result: 15 
wzy@wzypc:~/TCP-IP-NetworkNote/chapter-18$ 

18.5 线程的销毁和多线程并发服务器端的实现

18.5.1 销毁线程的 3 种方法

我怎么只看到 2 种。。

Linux 线程并不是在首次调用的线程 main 函数返回时自动销毁,所以用如下 2 种方法之一加以明确。否则由线程创建的内存空间将一直存在。

  • 调用 pthread_join 函数
  • 调用 pthread_detach 函数

第一种方法会进行阻塞,也已经介绍过。第二种方法的函数调用方式如下。

#include <pthread.h>
int pthread_detach(pthread_t thread);
/*
成功时返回 0,失败时返回其他值
thread:终止的同时需要销毁的线程 ID
*/

该方法不会进入阻塞,线程的主函数结束后之间释放资源。

15.5.2 多线程并发服务器端的实现

本节并不打算介绍回声服务器端,而是介绍多个客户端之间可以交换信息的简单的聊天程序。

代码参考 chat_server.c 文件和 chat_clnt.c 文件

运行结果

# 服务器端
wzy@wzypc:~/TCP-IP-NetworkNote/chapter-18$ gcc chat_server.c -D_REENTRANT -o cserv.exe -lpthread
wzy@wzypc:~/TCP-IP-NetworkNote/chapter-18$ gcc chat_clnt.c -D_REENTRANT -o cclnt.exe -lpthread
wzy@wzypc:~/TCP-IP-NetworkNote/chapter-18$ ./cserv.exe 9191
Connected client IP: 127.0.0.1 
Connected client IP: 127.0.0.1 
^C
wzy@wzypc:~/TCP-IP-NetworkNote/chapter-18$ 

# 客户端 1
wzy@wzypc:~/TCP-IP-NetworkNote/chapter-18$ ./cclnt.exe 127.0.0.1 9191 持续
fuck
[持续] fuck
[莲刃] wdnmd
q
wzy@wzypc:~/TCP-IP-NetworkNote/chapter-18$ 

# 客户端 2
wzy@wzypc:~/TCP-IP-NetworkNote/chapter-18$ ./cclnt.exe 127.0.0.1 9191 莲刃
[持续] fuck
wdnmd
[莲刃] wdnmd
q
wzy@wzypc:~/TCP-IP-NetworkNote/chapter-18$ 

服务器端代码中,应该掌握临界区的构成形式。『访问全局变量 clnt_cnt 和 数组 clnt_socks 的代码构成临界区』

18.6 习题

以下是我的理解,详细题目参照原书

  1. 单 CPU 系统中如何同时执行多个进程?请解释该过程中发生的上下文切换。

通过分时『同时』执行多个进程。

上下文切换,进程切换时必要的数据切换。

  1. 为何线程的上下文切换速度相对更快?线程间数据交换为何不需要类似 IPC 特别技术?

线程进行上下文切换需要切换的东西相对较少。

因为线程间有公共的内存空间。

  1. 请从执行流角度说明进程和线程的区别。

进程:在操作系统构成单独执行流的单位
线程:在进程构成单独执行流的单位

  1. 下面关于临界区的说法错误的是?

b、c、d

  1. 下列关于线程同步的描述错误的是?

d

  1. 请说明完全销毁 Linux 线程的 2 种办法

调用 pthread_join 函数,阻塞
调用 pthread_detach 函数,非阻塞

7、8 代码题

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值