《TCP/IP网络编程》第18章 多线程服务器端的实现
本章所有示例代码>>gtihub
18.1 理解线程的概念
1. 引入线程的背景
多进程模型的缺点:
- 创建进程的过程会带来一定的开销;
- 为了完成进程间数据交换,需要特殊的IPC技术;
- “每秒少则数十次、多则数千次的‘上下文切换’(ContextSwitching)是创建进程时最大的开销。”
单CPU系统中,系统将CPU时间分成多个微小的块后分配给了多个进程,为了分时使用CPU,需要“上下文切换”过程。
上下文切换:运行程序前需要将相应进程信息读入内存,如果运行进程A后需要紧接着运行进程B,就应该将进程A相关信息移出内存(移动到硬盘),并读入进程B相关信息。
线程相比于进程具有如下优点:
- 线程的创建和上下文切换比进程的创建和上下文切换更快;
- 线程间交换数据时无需特殊技术;
2. 线程和进程的差异
每个进程的内存空间都由保存全局变量的“数据区”、向molloc等函数的动态分配提供空间的堆(Heap)、函数运行时使用的栈(Stack)构成。每个进程都拥有这种独立空间。
线程为了保持多条代码执行流而隔开了栈区域。
进程:在操作系统构成单独执行流的单位;
线程:在进程构成单独执行流的单位;
18.2 线程创建及运行
1. 线程的创建和执行流程
线程具有单独的执行流,因此需要单独定义线程的main函数,还需要请求操作系统在单独的执行流中执行该函数。
#include <pthread.h>
int pthread_create(pthread_t*restrict thread, const pthread_attr_t *restrictattr, void * (* start_routine)(void *), void
*restrict arg); //成功时返回0,失败时返回其他值
-thread: 保存新创建线程ID的变量地址值。区分不同线程的ID;
-attr: 用于传递线程属性的参数,传递NULL时,创建默认属性的线程;
-start_routine: 相当于线程main函数的、在单独执行流中执行的函数地址值(函数指针);
-arg: 传递调用函数时包含传递参数信息的变量地址值;
restrict关键字、函数指针。
线程执行流程:
#include <pthread.h>
int pthread_join(pthread_tthread, void ** status);
// 成功时返回0,失败时返回其他值
-thread: 该参数ID的线程终止后才会从该函数返回;
-status: 保存线程的main函数返回值的指针变量地址值;
简言之,调用该函数的进程(或线程)将进入等待状态,直到第一个参数为ID的线程终止为止;
2. 可在临界区内调用的函数
关于多线程的运行需要考虑“多个线程同时调用函数时(执行时)可能产生问题”,这类函数内部存在临界区(CriticalSection),也就是说,多个线程同时执行这部分代码时,可能引起问题。
根据临界区是否引起问题,函数可分为以下2类:
- 线程安全函数(Thread-safefunction)
- 非线程安全函数(Thread-safefunction)
线程安全函数中也可能存在临界区,只是在线程安全函数中,同时被多个线程调用时可通过一些措施避免问题。
幸运的是,大多数标准函数都是线程安全的函数。相关平台在定义非线程安全函数的同时,提供了具有相同功能的线程安全函数。如gethostbyname是非线程安全的函数,gethostbyname_r是线程安全的函数。(Linux平台下线程安全函数的名称后缀通常为_r)
“声明头文件前定义_REENTRANT宏。”(也可以在编译时通过添加-D_REENTRANT选项定义宏)
3. 工作(Worker)线程模型
计算1到10的和。其中一个线程计算1到5的和,另一个线程计算6到10的和,main函数只负责输出运算结果。这种编程模型称为“工作线程(Workerthread)模型”。
18.3 线程存在的问题和临界区
1. 多个线程访问同一变量是问题
任何内存空间——只要被同时访问——都可能发生问题。
假设2个线程要执行将变量值逐次加1的工作。
需要注意值的增加方式,值的增加需要CPU运算完成,变量num中的值不会自动增加。线程1首先读该变量的值并将其传递到CPU,获得加1之后的结果100,最后再把结构写回变量num,这样num中就保存100。接着线程2再做处理可得正确结果。但是,线程1完全增加num值之前,线程2完全可能通过切换得到CPU资源。因此,线程访问变量num时,应该阻止其他线程访问,直到线程1完成运算,这就是同步(Synchronization)。
2. 临界区位置
“函数内同时运行多个线程时引起问题的多条语句构成的代码块。”
18.4 线程同步
1. 同步的两面性
线程同步用于解决线程访问顺序引发的问题。需要同步的情况可以从如下两方面入手:
- 同时访问同一内存空间时发生的情况;
- 需要指定访问同一内存空间的线程执行顺序的情况;
假设A、B两个线程,线程A负责向指定内存空间写入(保存)数据,线程B负责取走该数据。(应控制执行顺序)
2. 互斥量(Mutual Exclusion,Mutex)
Mutual Exclusion不允许多个线程同时访问。互斥量主要用于解决线程同步互斥问题。
互斥量的创建及销毁函数:
#include <pthread.h>
int pthread_mutex_init(pthread_mutex_t*mutex, const pthread_mutexattr_t *attr);
int pthread_mutex_destory(pthread_mutex_t*mutex);
//成功时返回0,失败时返回其他值
-mutex: 创建互斥量时传递保存互斥量的地址值,销毁时传递需要销毁的互斥量地址值;
-attr: 传递即将创建的互斥量属性,没有特别需要指定的属性时传递NULL;
利用互斥量加锁与解锁函数:
#include <pthread.h>
int pthread_mutex_lock(pthread_mutex_t*mutex);
int pthread_mutex_unlock(pthread_mutex_t*mutex);
// 成功时返回0,失败时返回其他值
进入临界区前调用的函数就是pthread_mutex_lock。调用该函数时,发现有其他线程已进入临界区,则pthread_mutex_lock函数不会返回,直到里面的线程调用pthread_mutex_unlock函数退出临界区为止(当前线程将一直处于阻塞状态)。
线程进入临界区时,如果忘了调用pthread_mutex_unlock函数,那么其他为了进入临界区而调用pthread_mutex_lock函数的线程就无法摆脱阻塞状态。这种情况称为“死锁”(Dead-lock)。
临界区划分较大——“最大限度减少互斥量lock、unlock函数的调用次数。”,因为互斥量lock、unlock函数调用过程时间花费较大。
3. 信号量(Semaphore)
信号量创建及销毁方法:
#include <semaphore.h>
int sem_init(sem_t *sem, int pshared,usigned int value);
int sem_destory(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_init函数时,操作系统将创建信号量对象,此对象中记录着“信号量值”(SemaphoreValue)整数,该值在调用sem_post函数时增1,在调用sem_wait函数时减1。
但信号量的值不能小于0。因此,在信号量为0的情况下调用sem_wait函数时,调用函数的线程将进入阻塞状态(因为函数未返回)。此时如果有其他线程调用sem_post函数,信号量的值将变为1,而原本阻塞的线程可以将信号量重新减为0并跳出阻塞状态。
假设信号量初始值为1,完成临界区的同步操作:
sem_wait(&sem);
// 临界区
sem_post(&sem);
上述代码结构中,调用sem_wait函数进入临界区的线程在调用sem_post函数前不允许其他线程进入临界区。信号量的值在0和1之间跳转,因此,具有这种特性的机制称为“二进制信号量”。
信号量用于控制各线程按既定顺序访问资源。
18.5 线程的销毁和多线程并发服务器端的实现
1. 线程销毁的3种方法
- 调用pthread_join函数;
- 调用pthread_detach函数;
调用该函数时,不仅会等待线程终止,还会引导线程销毁,但该函数的问题是,线程终止前,调用该函数的线程将进入阻塞状态。通常通过如下函数调用引导线程销毁:
#include <pthread.h>
int pthread_detach(pthread_t thread);
// 成功时返回0,失败时返回其他值
可以通过该函数引导销毁线程创建的内存空间。