六、线程
38. 什么是线程
- 线程是参与系统调度的最小单位,它被包含在进程之中,是进程中的实际运行单位
- 一个进程中可以创建多个线程,多个线程实现并发运行,每个线程执行不同的任务
- 主线程:当一个程序启动时,就有一个进程被操作系统创建,同时一个线程也立刻运行,通常叫主线程(Main Thread)
- 应用程序都是以 main() 做为入口开始运行的,所以 main() 函数就是主线程的入口函数,main() 函数所执行的任务就是主线程需要执行的任务
- 多线程:任何一个进程都包含一个主线程,只有主线程的进程称为单线程进程,多线程指的是除了主线程以外,还包含其它的线程,其它线程通常由主线程来创建(调用 pthread_create 创建),创建的新线程就是主线程的子线程
- 其它新的线程(也就是子线程)由主线程创建
- 主线程通常会在最后结束运行,执行各种清理工作,如:回收各个子线程
- 线程特点
- 进程不能运行,真正运行的是进程中的线程
- 同一进程中的多个线程将共享该进程中的全部系统资源,如虚拟地址空间,文件描述符和信号处理等。但同一进程中的多个线程有各自的调用栈(call stack,称为线程栈)、寄存器环境及线程本地存储
- 线程不能单独存在,而是包含在进程中
- 同一进程的多个线程之间可并发执行,在宏观上实现同时运行的效果
39. 线程与进程对比
- 线程
- LWP:light weight process 轻量级的进程,本质仍是进程 (Linux 下)
- 有独立的 PCB,但没有独立的地址空间 (共享)
- 最小的运行单位
- 进程
- 有独立地址空间,拥有 PCB
- 最小的资源分配单位,可看成是只有一个线程的进程
进程与线程的区别在于是否共享地址空间:独居(进程)、合租(线程)
40. 并发和并行对比
-
串行
- 串行指的是一种顺序执行,如:先完成 task1,接着做 task2、直到完成 task2,然后做 task3、直到完成 task3…… 依次按照顺序完成每一件事情,必须要完成上一件事才能去做下一件事,只有一个执行单元,这就是串行运行
- 串行指的是一种顺序执行,如:先完成 task1,接着做 task2、直到完成 task2,然后做 task3、直到完成 task3…… 依次按照顺序完成每一件事情,必须要完成上一件事才能去做下一件事,只有一个执行单元,这就是串行运行
-
并行
-
并行指的是可以并排/并列执行多个任务,这样的系统通常有多个执行单元可以实现并行运行,如:并行运行 task1、task2、task3
-
并行运行并不一定要同时开始运行、同时结束运行,只需满足在某一个时间段上存在多个任务被多个执行单元同时在运行着
-
-
并发
- 相比于串行和并行,并发强调的是一种分时复用
- 分时复用:不必等待上一个任务完成之后再做下一个任务,可以打断当前执行的任务切换执行下一个任务
- 在同一个执行单元上,将时间分解成不同的片段(时间片),每个任务执行一段时间,时间一到则切换执行下一个任务,依次这样轮训(交叉/交替执行),这就是并发运行
- 相比于串行和并行,并发强调的是一种分时复用
总结
- 串行:一件事、一件事接着做
- 并行:同时做不同的事(并行运行情况下的多个执行单元,每一个执行单元同样也可以并发运行)
- 并发:交替做不同的事
- 多核处理器和单核处理器
- 对于单核处理器来说,只有一个执行单元,同时只能执行一条指令
- 对于多核处理起来说,有多个执行单元,在操作系统中,多个执行单元以并行方式运行多个进程,同时每一个执行单元以并发方式运行系统中的多个线程(进程级并行和线程级并发)
- 在单个处理核心虽然以并发方式运行着系统中的线程(微观上交替/交叉方式运行不同的线程),但在宏观上所表现出来的效果是同时运行着系统中的所有线程,因为处理器的运算速度太快了,交替轮训一次所花费的时间在宏观上几乎是可以忽略不计的,所以表示出来的效果就是同时运行着所有线程
41. 多进程并发和多线程并发对比
进程创建多个子进程可以实现并发处理多任务(本质上便是多个单线程进程),多线程同样也可以实现(一个多线程进程)并发处理多任务的需求
- 多进程编程的劣势
- 进程间切换开销大
- 多个进程同时运行(宏观上),微观上依然是轮流切换运行,进程间切换开销远大于同一进程的多个线程间切换的开销,通常对于一些中小型应用程序来说不划算
- 进程间通信较为麻烦
- 每个进程都在各自的地址空间中,相互独立、隔离,因此相互通信较为麻烦
- 进程间切换开销大
- 多线程编程的优势
- 同一进程的多个线程间切换开销比较小
- 同一进程的多个线程间通信容易
- 它们共享了进程的地址空间,所以在同一个地址空间中
- 线程创建的速度远大于进程创建的速度
- 多线程在多核处理器上更有优势
- 对比
- 多线程编程难度高,在多线程环境下需要考虑很多的问题,如:线程安全问题、信号处理的问题等,编写与调试一个多线程程序比单线程程序困难得多
- 多进程编程通常会用在一些大型应用程序项目中,如:网络服务器应用程序
42. 创建线程 pthread_create()
- 主线程使用库函数 pthread_create() 创建一个新的线程,称为主线程的子线程
- 线程创建成功,新线程就会加入到系统调度队列中,获取 CPU 后就会立马从 start_routine() 函数开始运行该线程的任务
- 调用 pthread_create() 函数后,通常无法确定系统接着会调度哪一个线程来使用 CPU 资源,无法确定先调度主线程还是新创建的线程(而在多核 CPU 或多 CPU 系统中,多核线程可能会在不同的核心上同时执行)
- 如果程序对执行顺序有强制要求,那么就必须采用一些同步技术来实现
43. 终止线程的方式
- 线程的 start 函数执行 return 语句并返回指定值,返回值就是线程的退出码
- 线程调用 pthread_exit() 函数终止调用它的线程
- 调用 pthread_exit() 相当于在线程的 start 函数中执行 return 语句
- 不同之处在于:可在线程 start 函数所调用的任意函数中调用 pthread_exit() 来终止线程
- 如果主线程调用了 pthread_exit(),那么主线程也会终止,但其它线程依然正常运行,直到进程中的所有线程终止才会使得进程终止
- 调用 pthread_cancel() 取消线程
44. 回收线程 pthread_join()
- 在父、子进程当中,父进程可通过 wait() 函数(或 waitpid())阻塞等待子进程退出并获取其终止状态,回收子进程资源;而在线程当中通过调用 pthread_join() 函数来阻塞等待线程终止,并获取线程的退出码以回收线程资源
- 如果该线程已经终止,则 pthread_join() 立刻返回
- 如果多个线程同时尝试调用 pthread_join() 等待指定线程的终止,那么结果将是不确定的
- 若线程并未分离(detached),则必须使用 pthread_join() 来等待线程终止,回收线程资源
- 如果线程终止后,其它线程没有调用 pthread_join() 函数来回收该线程,那么该线程将变成僵尸线程
- 不能以非阻塞的方式调用 pthread_join(),而对于进程,调用 waitpid() 既可以实现阻塞方式等待,也可以实现非阻塞方式等待
45. 取消线程 pthread_cancel()
- 通常,进程中的多个线程会并发执行,每个线程各司其职,直到线程的任务完成之后,该线程中会调用 pthread_exit() 退出,或在线程 start 函数执行 return 语句退出
- 有时需要向一个线程发送一个请求,要求它立刻退出,把这种操作称为取消线程,也就是向指定的线程发送一个请求,要求其立刻终止、退出。如:一组线程正在执行一个运算,一旦某个线程检测到错误发生,需要其它线程退出,便可使用取消线程
- 通过调用库函数 pthread_cancel() 向一个指定的线程发送取消请求
- 发出取消请求后,函数 pthread_cancel() 立即返回,不会等待目标线程的退出,仅仅只是提出请求
46. 分离线程 pthread_detach()
- 默认情况下,当线程终止时,其它线程可以通过调用 pthread_join() 获取其返回状态并回收线程资源,有时并不关心线程的返回状态,只希望系统在线程终止时能够自动回收线程资源并将其移除
- 这种情况下,可以调用 pthread_detach() 将指定线程进行分离,也就是分离线程
- 一个线程既可以将另一个线程分离,同时也可以将自己分离
- 一旦线程处于分离状态,就不能再使用 pthread_join() 来获取其终止状态,此过程是不可逆的,一旦处于分离状态便不能再恢复到之前的状态,处于分离状态的线程,当其终止后能够自动回收线程资源
47. 线程与信号
- 线程与信号之间存在一些冲突,原因在于:信号既要能够在传统的单线程进程中保持它原有的功能与特性,同时又需要设计出能够适用于多线程环境的新特性
- 应尽量避免信号与多线程模型之间结合使用
七、线程同步
48. 为什么需要线程同步?
- 线程同步是为了对共享资源的访问进行保护
- 共享资源指的是多个线程都会进行访问的资源(如:全局变量)
- 保护的目的是为了解决数据一致性的问题
- 当一个线程可以修改的变量,其它的线程也可以读取或者修改的时候,就存在数据一致性的问题,需要对这些线程进行同步操作,确保它们在访问变量的存储内容时不会访问到无效值
- 出现数据一致性问题的本质:进程中的多个线程对共享资源的并发访问(同时访问)
49. 线程同步机制如何选择?
- 互斥锁
- 一次只允许一个线程访问临界区,其他线程需要等待释放锁,适合对共享资源进行独占式访问控制
- 读写锁
- 允许多个线程同时读取共享资源,但在写入时要求互斥,适合对共享资源进行读取频率高于写入频率
- 条件变量
- 允许线程在满足某个条件之前等待,通过通知唤醒相应的等待线程,适合某些条件满足时进行同步
- 信号量
- 控制多个线程对有限资源的访问权限,适合在资源数目有限且需要精确控制访问权限的情况
- 原子操作
- 保证特定操作的原子性,不会被中断或干扰,适合在对共享变量进行简单读取和写入操作时进行同步
50. 互斥锁及其操作流程
- 互斥锁(mutex)又叫互斥量,从本质上说是一把锁
- 在访问共享资源前对互斥锁进行上锁,在访问完成后释放互斥锁(解锁)
- 对互斥锁进行上锁之后,任何其它试图再次对互斥锁进行加锁的线程都会被阻塞,直到当前线程释放互斥锁
- 如果释放互斥锁时有一个以上的线程阻塞,那么这些阻塞的线程会被唤醒,它们都会尝试对互斥锁进行加锁,当有一个线程成功对互斥锁上锁之后,其它线程就不能再次上锁,只能再次陷入阻塞,等待下一次解锁
- 互斥锁使用 pthread_mutex_t 数据类型表示,在使用互斥锁之前,必须首先对它进行初始化操作
- 1. 互斥锁初始化
- 使用 PTHREAD_MUTEX_INITIALIZER 宏初始化互斥锁,只适用于在定义的时候就直接进行初始化
- 使用 pthread_mutex_init() 函数初始化互斥锁,适用于:先定义互斥锁然后再进行初始化,或者在堆中动态分配(malloc())的互斥锁
- 2. 互斥锁加锁和解锁
- 调用 pthread_mutex_lock() 函数对互斥锁进行上锁
- 如果互斥锁处于未锁定状态,则此次调用会上锁成功,函数调用将立马返回
- 如果互斥锁此时已经被其它线程锁定了,那么调用 pthread_mutex_lock() 会一直阻塞,直到该互斥锁被解锁,到那时,调用将锁定互斥锁并返回
- 调用 pthread_mutex_unlock() 函数将已经处于锁定状态的互斥锁进行解锁
- 不能对处于未锁定状态的互斥锁进行解锁操作
- 不能解锁由其它线程锁定的互斥锁
- pthread_mutex_trylock() 尝试加锁
- 当互斥锁已经被其它线程锁住时,调用 pthread_mutex_lock() 函数会被阻塞,直到互斥锁解锁
- 如果线程不希望被阻塞,可以使用 pthread_mutex_trylock() 函数尝试对互斥锁进行加锁,如果互斥锁处于未锁住状态,那么调用 pthread_mutex_trylock() 将会锁住互斥锁并立马返回,如果互斥锁已经被其它线程锁住,调用 pthread_mutex_trylock() 加锁失败,但不会阻塞,而是返回错误码 EBUSY
- 调用 pthread_mutex_lock() 函数对互斥锁进行上锁
- 3. 销毁互斥锁
- 当不再需要互斥锁时,应将其销毁,调用 pthread_mutex_destroy() 函数来销毁互斥锁
- 不能销毁还没有解锁的互斥锁,否则将会出现错误
- 没有初始化的互斥锁也不能销毁
- 被销毁后的互斥锁不能再对它进行上锁和解锁,需再次调用 pthread_mutex_init() 对互斥锁进行初始化之后才能使用
- 当不再需要互斥锁时,应将其销毁,调用 pthread_mutex_destroy() 函数来销毁互斥锁
51. 互斥锁死锁
- 如果一个线程试图对同一个互斥锁加锁两次,该线程会陷入死锁状态,一直被阻塞永远出不来
- 有时一个线程需要同时访问两个或更多不同的共享资源,而每个资源又由不同的互斥锁管理
- 如果允许线程 A 一直占有第一个互斥锁,并且在试图锁住第二个互斥锁时处于阻塞状态,但是拥有第二个互斥锁的线程 B 也在试图锁住第一个互斥锁
- 因为线程 A、B 都在相互请求另一个线程拥有的资源,所以这两个线程会一直阻塞,产生死锁
// 线程 A pthread_mutex_lock(mutex1); pthread_mutex_lock(mutex2); // 死锁 // 线程 B pthread_mutex_lock(mutex2); pthread_mutex_lock(mutex1); // 死锁
- 解决互斥锁死锁的方法
- 1、定义互斥锁的层级关系,当多个线程对一组互斥锁操作时,总是应该按照相同的顺序对该组互斥锁进行锁定。如在上述场景中,如果线程 A、B 总是先锁定 mutex1 再锁定 mutex2,死锁就不会出现
- 2、使用 pthread_mutex_trylock() 以不阻塞的方式尝试对互斥锁进行加锁:先用 pthread_mutex_lock() 锁定第一个互斥锁,然后使用 pthread_mutex_trylock() 锁定其余互斥锁,若任一 pthread_mutex_trylock() 调用失败,那么该线程释放所有互斥锁,可以经过一段时间之后从头再试
52. 自旋锁
- 如果在获取自旋锁时,自旋锁处于未锁定状态,那么将立即获得锁(对自旋锁上锁);如果在获取自旋锁时,自旋锁已经处于锁定状态,那么获取锁操作将会在原地 “自旋”,直到该自旋锁的持有者释放了锁
- “自旋” 其实就是调用者一直在循环查看该自旋锁的持有者是否已经释放了锁
- 自旋锁的不足之处
- 自旋锁一直占用着 CPU,在未获得锁的情况下,一直处于运行状态(自旋),所以占着 CPU,如果不能在很短的时间内获取锁,会使 CPU 效率降低
- 试图对同一自旋锁加锁两次必然会导致死锁,而试图对同一互斥锁加锁两次不一定会导致死锁,原因在于:当互斥锁设置为 PTHREAD_MUTEX_ERRORCHECK 类型时,会进行错误检查,第二次加锁会返回错误,所以不会进入死锁状态
- 自旋锁与互斥锁之间的区别
- 实现方式上的区别
- 互斥锁是基于自旋锁而实现的,所以自旋锁相较于互斥锁更加底层
- 开销上的区别
- 休眠与唤醒开销是很大的,所以互斥锁的开销要远高于自旋锁,自旋锁的效率远高于互斥锁
- 但如果长时间的 “自旋” 等待,会使得 CPU 使用效率降低,故自旋锁不适用于等待时间比较长的情况
- 使用场景的区别
- 自旋锁在用户态应用程序中使用的比较少,通常在内核代码中使用比较多
- 自旋锁可以在中断服务函数中使用,而互斥锁则不行,在执行中断服务函数时要求不能休眠、不能被抢占(内核中使用自旋锁会自动禁止抢占),一旦休眠意味着执行中断服务函数时主动交出了 CPU 使用权,休眠结束时无法返回到中断服务函数中,这样就会导致死锁
- 实现方式上的区别
53. 读写锁
-
互斥锁或自旋锁要么是加锁状态、要么是不加锁状态,而且一次只有一个线程可以对其加锁
-
读写锁有 3 种状态:读模式下的加锁状态(读加锁状态)、写模式下的加锁状态(写加锁状态)和不加锁状态
- 一次只有一个线程可以占有写模式的读写锁(写独占)
- 但一次可以有多个线程同时占有读模式的读写锁(读共享)
- 读写锁比互斥锁具有更高的并行性
-
读写锁有如下两个规则
- 当读写锁处于写加锁状态时,在这个锁被解锁之前,所有试图对这个锁进行加锁操作(不管是以读模式加锁还是以写模式加锁)的线程都会被阻塞
- 当读写锁处于读加锁状态时,所有试图以读模式对它进行加锁的线程都可以加锁成功,但是任何以写模式对它进行加锁的线程都会被阻塞,直到所有持有读模式锁的线程释放它们的锁为止
-
读写锁适合于对共享数据读的次数远大于写次数的情况,读写锁也叫做共享互斥锁
-
1. 读写锁初始化
- 使用宏 PTHREAD_RWLOCK_INITIALIZER 或函数 pthread_rwlock_init(),其初始化方式与互斥锁相同
-
2. 读写锁上锁和解锁
- 以读模式对读写锁进行上锁,需要调用 pthread_rwlock_rdlock() 函数
- 以写模式对读写锁进行上锁,调用 pthread_rwlock_wrlock() 函数
- 不管是以何种方式锁住读写锁,均可以调用 pthread_rwlock_unlock() 解锁
-
3. 销毁读写锁
- 当读写锁不再使用时,需要调用 pthread_rwlock_destroy() 函数将其销毁
54. 条件变量
- 条件变量本身不是锁,但它可以用于自动阻塞线程,直到某个特定事件发生或某个条件满足为止
- 通常情况下,条件变量是和互斥锁一起搭配使用的
- 使用条件变量主要包括两个动作
- 一个线程等待某个条件满足而被阻塞
- 在收到一个通知前一直处于阻塞状态
- 调用 pthread_cond_wait() 函数是线程阻塞,直到收到条件变量的通知
- 另一个线程中,条件满足时发出 “信号”
- 通知一个或多个处于等待状态的线程,某个共享变量的状态已经改变,这些处于等待状态的线程收到通知之后便会被唤醒,唤醒之后再检查条件是否满足
- 函数 pthread_cond_signal() 和 pthread_cond_broadcast() 均可向指定的条件变量发送信号,通知一个或多个处于等待状态的线程
- 一个线程等待某个条件满足而被阻塞
- 条件变量的优点
- 相较于 mutex 而言,条件变量可以减少竞争
- 若直接使用 mutex,除了生产者、消费者之间要竞争互斥量以外,消费者之间也需要竞争互斥量,但如果汇聚 (链表) 中没有数据,消费者之间竞争互斥锁是无意义的
- 有了条件变量机制以后,只有生产者完成生产,才会引起消费者之间的竞争,提高了程序效率
- 相较于 mutex 而言,条件变量可以减少竞争
55. 生产者-消费者条件变量模型
- 假定有两个线程,一个模拟生产者行为,一个模拟消费者行为。两个线程同时操作一个共享资源 (一般称之为汇聚),生产者向其中添加产品,消费者从中消费掉产品
56. 信号量
- 信号量相当于初始化值为 N 的互斥量
- 由于互斥锁的粒度比较大,如果希望在多个线程间对某一对象的部分数据进行共享,使用互斥锁是没有办法实现的,只能将整个数据对象锁住。这样虽然达到了多线程操作共享数据时保证数据正确性的目的,却无形中导致线程的并发性下降。线程从并行执行,变成了串行执行,与直接使用单进程无异
- 信号量是相对折中的一种处理方式,既能保证同步、数据不混乱,又能提高线程并发