什么是多线程?
多线程是一种允许并发(同时)执行程序的两个或多个部分以最大程度地利用 CPU 的技术。作为一个非常基本的示例,多线程允许您在一个程序中编写代码并在另一个程序中听音乐。程序由进程和线程组成。你可以这样想:
- 程序是可执行文件,例如 chrome.exe
- 进程是程序的执行实例。当您双击计算机上的 Google Chrome 图标时,您将启动一个将运行 Google Chrome 程序的进程。
- 线程是进程的最小可执行单元。一个进程可以有多个线程和一个主线程。在示例中,单个线程可能显示您所在的当前选项卡,而不同的线程可能是另一个选项卡。
多线程示例
考虑运行 IDE 的单个处理器。假设您编辑了一个代码文件并单击保存。当您单击保存时,它将启动一个工作流,该工作流会将字节写出到底层物理磁盘。但是,IO 是一项开销很大的操作,在将字节写入磁盘时 CPU 将处于空闲状态。
当 IO 发生时,空闲的 CPU 可以做一些有用的事情,这就是线程进来的地方——IO 线程被关闭,UI 线程被安排在 CPU 上,这样如果你点击屏幕上的其他地方,你的 IDE 仍然反应灵敏,不会出现挂起或冻结。
线程可以给人一种多任务处理的错觉,即使在任何给定的时间点 CPU 只执行一个线程。每个线程在 CPU 上获得一段时间,然后被切换出去。
它启动一个任务,该任务需要等待且不使用 CPU,或者完成其在 CPU 上的时隙。关于线程调度的工作方式还有更多细微差别和复杂性,但这构成了它的基础。
随着硬件技术的进步,现在多核机器已经很普遍了。应用程序可以利用这些优势,并让一个专用 CPU 运行每个线程。
为什么要使用多线程?
随着多核的引入,就应用程序的效率而言,多线程已变得极其重要。对于多线程和单核,您的应用程序将不得不来回转换以产生多任务处理的错觉。
借助多核,您的应用程序可以利用底层硬件通过专用内核运行各个线程,从而使您的应用程序响应更快、效率更高。多线程基本上允许您充分利用您的 CPU 和多个内核,因此您没有闲置内核的未开发处理能力。
出于以下几个原因,开发人员应该使用多线程:
- 更高的吞吐量
- 响应式应用程序给人以多任务处理的错觉。
- 高效利用资源。与生成全新进程相比,线程创建是轻量级的,对于在处理 Web 请求时使用线程而不是创建新进程的 Web 服务器,消耗的资源要少得多。
请注意,您不能不断添加线程并期望您的应用程序运行得更快。更多线程意味着更多问题,您必须仔细周到地设计它们如何协同工作。在某些情况下,您甚至可能希望完全避免多线程,尤其是当您的应用程序执行大量顺序操作时。
了解线程的工作原理和并发编程原理的知识将展示开发人员的成熟度和技术深度。这也是在公司获得更高级职位的重要区别因素。
多线程的基本概念
程序、进程和线程
今天的操作系统可以同时运行多个程序。 例如,您正在浏览器(一个程序)中阅读这篇文章,但您也可以在媒体播放器(另一个程序)中收听音乐。
进程是实际执行程序的。每个进程都能够运行称为线程的并发子任务。
线程是进程的子任务,如果正确同步,可以给人一种错觉,即您的应用程序正在同时执行所有操作。如果没有线程,您将不得不为每个任务编写一个程序,将它们作为进程运行并通过操作系统同步它们。
并发
并发性是您的程序一次处理(而不是做)很多事情的能力,它是通过多线程实现的。不要将并发与并行性混淆,并行性是一次做很多事情。
上下文切换
上下文切换是一种在所有正在运行的进程之间共享 CPU 时间的技术,它是多任务处理的关键。
线程池
线程池允许您分离任务提交和执行。您可以选择在部署应用程序时公开执行程序的配置,或者无缝地将一个执行程序切换为另一个执行程序。
线程池由分配给执行任务的同类工作线程组成。一旦工作线程完成任务,它就会返回到池中。通常,线程池绑定到一个队列,任务从该队列中出队以供工作线程执行。
线程池可以根据它持有的线程的大小进行调整。如果线程死于意外异常,线程池也可以替换它。使用线程池可以立即减轻手动创建线程的麻烦。关于线程池的重要说明:
- 线程接收和处理请求时没有延迟,因为创建线程不会浪费时间。
- 系统不会内存不足,因为线程不是无限制创建的
- 微调线程池将使我们能够控制系统的吞吐量。我们可以有足够的线程来保持所有处理器忙碌,但又不会多到让系统不堪重负。
- 如果系统负载不足,应用程序将正常降级。
锁定
锁是使多线程成为可能的一个非常重要的特性。锁是一种同步技术,用于在有许多执行线程的环境中限制对资源的访问。锁的一个很好的例子是互斥量。
互斥锁
Mutex 顾名思义就是互斥。互斥锁用于保护共享数据,例如链表、数组或任何简单的原始类型。互斥量只允许单个线程访问资源。
线程安全
线程安全是一个概念,意味着不同的线程可以访问相同的资源而不会暴露错误行为或产生不可预测的结果,如竞争条件或死锁。线程安全可以通过使用各种同步技术来实现。
涉及多线程的问题
僵局
当两个或多个线程无法取得任何进展时,就会发生死锁,因为第一个线程所需的资源由第二个线程持有,而第二个线程所需的资源由第一个线程持有。
竞争条件
临界区是任何一段代码,它有可能被应用程序的多个线程并发执行,并暴露应用程序使用的任何共享数据或资源以供访问。
当线程在没有线程同步的情况下运行通过临界区时,就会发生竞争条件。线程“竞速”通过临界区以写入或读取共享资源,并且根据线程完成“竞速”的顺序,程序输出会发生变化。
在竞争条件下,线程访问可能由其他线程同时处理的共享资源或程序变量,从而导致应用程序数据不一致。
饥饿
除了死锁之外,应用程序线程还可能遇到饥饿,因为其他“贪婪”的线程占用了资源,所以它永远无法获得 CPU 时间或访问共享资源。
活锁
当两个线程继续采取行动以响应另一个线程而不是取得任何进展时,就会发生活锁。最好的类比是想象两个人试图在走廊里穿过对方。约翰向左移动让阿伦通过,阿伦向右移动让约翰通过。
两者现在互相阻止。John 看到他现在挡住了 Arun 并向他的右边移动,而 Arun 向他的左边移动,看到他挡住了 John。他们从不交叉,一直互相阻挡。此场景是活锁的示例。
如何避免多线程问题
如何避免死锁?
- 避免嵌套锁: 这是造成死锁的主要原因。死锁主要发生在我们给多个线程加锁的时候。如果您已经给了一个线程,请避免给多个线程加锁。
- 避免不必要的锁定: 您应该只锁定那些需要的成员。拥有不必要的锁会导致死锁。
作为最佳实践,尽量减少锁定事物的需要。
如何避免竞争条件?
竞争条件发生在代码的关键部分。这些可以通过使用锁、原子变量和消息传递等技术在关键部分内通过适当的线程同步来避免。
如何避免饿死?
避免饥饿的最好方法是使用锁,例如 ReentrantLock 或互斥锁。这引入了一个“公平”锁,它有利于授予对等待时间最长的线程的访问权限。如果你想同时运行多个线程同时防止饥饿,你可以使用信号量。
如何避免活锁?
可以通过使用 ReentrantLock 作为确定哪个线程等待时间更长的方式来避免活锁,以便您可以为其分配锁。作为最佳实践,不要阻塞锁;如果一个线程不能获得锁,它应该释放之前获得的锁,稍后再试。