人们一提起 JavaScript 就会想起单线程,那么为什么会这样呢?这经得住推敲吗?不同的执行环境又有什么差异呢?
带着这些问题,我通过自问自答的方式,整理了一份关于 Javascript 单线程的知识汇总
整体的思考过程如下图,每个问题在后面有单独的回答
1. JavaScript 的单线程模型,具体是什么含义?
JavaScript的单线程模型指的是在给定的执行环境(如一个浏览器标签页或一个Node.js进程)中,JavaScript 代码的执行是在单个线程上顺序进行的。这意味着一次只能执行一个任务,其他任务必须等待当前执行中的任务完成后才能执行。
其具体含义包括:
- 同步执行: 代码按照书写的顺序从上到下执行,一个函数的执行必须等待上一个函数执行完毕。
- 无竞态条件: 由于同时只有一个任务在执行,因此 不会出现多线程编程中常见的竞态条件问题。
- 事件队列: 单线程模型采用事件队列机制来处理异步事件。当异步事件发生时(如点击事件、网络请求返回等),它们会被放入事件队列中,等待当前执行栈中的所有代码执行完毕后,事件循环再按顺序从事件队列中取出事件进行处理。
- 非阻塞I/O: 为了避免长时间的I/O操作阻塞线程,JavaScript环境通常提供了非阻塞的I/O操作(如网络请求、文件读取等),允许代码在发起I/O操作后立即继续执行,而I/O结果会在未来某个时刻通过回调函数、Promise等形式处理。
- Web Workers: 虽然JavaScript自身是单线程的,但现代浏览器提供了Web Workers允许开发者创建真正的后台线程来执行复杂计算,不过这些线程无法直接访问DOM。
单线程模型简化了开发者对程序执行流程的理解和状态管理,但也意味着开发者需要注意编写非阻塞代码,以避免长时间的计算或同步I/O操作导
2. 这是否主要是因为,JavaScript 语言本身没有提供良好的跨线程能力,例如锁机制等?
JavaScript 语言最初设计为运行在浏览器环境中,用于增强用户与网页之间的交互。因此,它的设计哲学是尽量简单,避免复杂性,以便于开发人员编写和维护。在早期的Web开发中,页面通常不需要执行复杂的计算任务,因此单线程模型足够满足需求。
单线程模型的确是因为JavaScript语言在设计之初没有包括多线程编程的能力。这样的设计可以避免多线程编程中的一些困难:
- 锁机制: 在多线程环境中,为了同步资源访问,通常需要复杂的锁机制。JavaScript省略了这些复杂性,因为一次只执行一个任务,资源不会同时被多个任务访问。
- 竞态条件: 多线程编程易于出现竞态条件,这需要开发者仔细设计程序以防止错误。JavaScript的单线程模型避免了这些问题。
- 上下文切换: 多线程编程中,线程间的上下文切换可能会导致性能下降。JavaScript的单线程模型没有这个问题。
3. 浏览器的 event loop 实现方式,是单线程的吗?
是的,浏览器中的事件循环(event loop)是基于单线程的。尽管现代浏览器是多进程的,每个标签页通常都有自己的进程,但事件循环是在每个标签页的渲染进程中独立执行的,并且是单线程的。这意味着在给定的标签页中,JavaScript 代码是在单个线程上按顺序执行的,这个线程通常被称为“主线程” 或 “UI线程”。
主线程负责执行代码、渲染界面、处理事件等任务。尽管如此,浏览器还提供了 Web Workers,允许开发者在后台线程上运行代码,但这些线程不能直接访问 DOM,并且与主线程的通信是通过消息传递的方式进行的,以保持界面的响应性。
4. Nodejs 的底层 (libuv) 的 event loop 实现方式,是单线程的吗?
是的,libuv 的事件循环(event loop)默认情况下是单线程的。libuv 是一个高性能的异步I/O库,常用于支持 Node.js 的非阻塞I/O操作。它提供了一个跨平台的事件循环,用于处理文件系统操作、网络操作等。
在 libuv 中,事件循环运行在一个单一的线程上,通常是应用程序的主线程。所有的异步事件(如文件I/O完成通知、网络消息到达等)都会被推送到事件队列中,然后由事件循环依次处理。
虽然 libuv 的事件循环是单线程的,但它 也使用了线程池来执行一些阻塞的操作,比如文件系统I/O,以避免阻塞主线程。当这些操作完成时,它们的结果会被推回事件循环,由主线程处理完成事件。
因此,虽然 libuv 本身处理事件的方式是单线程的,但它通过工作线程来支持并发执行耗时任务,这样就可以在不阻塞主线程的情况下处理这些任务。
libuv 官网标注的使用线程池的任务类型包括
- File system operations:文件系统操作
- DNS functions (
getaddrinfo
andgetnameinfo
):DNS 相关函数(getaddrinfo
和getnameinfo
) - User specified code via
uv_queue_work()
:用户通过uv_queue_work()
指定代码。
此外,nodejs 线程池的大小是可以配置的。默认是 4 个线程。
5. 如何使用 Web Workers?
Web Workers 允许你在后台线程中运行代码,从而不阻塞主线程。这对于执行耗时的计算任务特别有用,因为它们不会冻结用户界面或影响前端的响应性。
场景
假设你需要在一个网页应用中执行复杂的数据处理或计算密集型任务,比如图像处理、大量数据排序、执行复杂算法等。
示例代码
下面是一个简单的使用Web Workers的示例,其中我们将创建一个worker来执行耗时的任务(比如计算斐波那契数列)。
主线程代码(main.js):
// 检查是否支持Web Workers
if (window.Worker) {
// 创建一个新的Worker对象,并指定要运行的脚本
const myWorker = new Worker('worker.js');
// 发送数据到worker
myWorker.postMessage(10); // 假设我们要计算斐波那契数列的第10个数字
// 监听来自worker的消息
myWorker.onmessage = function(e) {
console.log('Message received from worker:', e.data);
};
// 监听错误
myWorker.onerror = function(e) {
console.error('Error occurred in worker:', e);
};
} else {
console.log('Your browser doesn\'t support web workers.');
}
Worker线程代码(worker.js):
// 监听主线程发来的消息
onmessage = function(e) {
console.log('Message received from main script:', e.data);
const result = fibonacci(e.data);
postMessage(result);
};
// 斐波那契数列递归函数
function fibonacci(n) {
return n < 2 ? n : fibonacci(n - 1) + fibonacci(n - 2);
}
// 监听错误事件
onerror = function(e) {
console.error('Error occurred in worker:', e);
};
在上面的例子中,主线程通过 postMessage
方法向 worker
发送一个消息,worker
接收这个消息,并开始执行计算斐波那契数列的函数。计算完成后,worker
通过 postMessage
方法将结果发送回主线程。主线程通过 onmessage
事件监听器来接收这个结果。
注意事项
- Worker 线程无法直接访问 DOM 元素。(原因前面已经介绍过了,这样避免了资源竞争,即想通 DOM 元素的并发修改)
- Workers 之间以及 Workers 与主线程之间只能通过消息传递(拷贝或转移数据)进行通信,不能共享状态。
- Workers 中不能访问一些全局变量和函数,比如
window
、document
等。(道理类似)
6. nodejs 都有哪些多线程能力?他们的使用场景是什么?
Node.js 提供了多种方式来实现多线程和并行处理,每种方式都适用于不同的场景:
- Worker Threads:
- 能力: 提供真正的多线程支持,每个线程都有自己的V8实例。
- 场景: CPU密集型任务,如加密、数据分析、图像处理等。
- 注意点: 需要小心处理线程间的通信和状态共享,以避免竞态条件。
- Cluster 模块:
- 能力: 创建多个进程,进程间不共享内存,但可以共享服务器端口。
- 场景: 提高网络服务的吞吐量,如Web服务器、API服务等。
- 注意点: 主要用于分摊负载和提高容错性,并不提供共享状态的能力。
- child_process 模块:
- 能力: 创建子进程来执行命令或独立的Node.js脚本。
- 场景: 需要与系统命令交互或运行与主进程独立的任务。
- 注意点: 子进程的资源使用和管理需要仔细控制,以避免过多消耗系统资源。
使用场景举例
- 使用 Worker Threads 进行图像处理:图像处理通常需要大量的CPU资源进行像素计算。你可以在Worker线程中进行图像处理,以免阻塞主线程。
- 使用 Cluster 模块扩展 Web 服务器:当运行一个Node.js网站时,你可以使用Cluster模块来创建多个子进程,这些子进程可以并行处理更多的用户请求。(感觉上这很类似于 nginx 的多进程)
- 使用 child_process 运行脚本或命令:
当你需要执行系统级别的任务,如脚本运行、文件压缩等,可以通过创建子进程来并行执行,而不会影响主进程。
优点
Node.js 的多线程和并发处理能力提供了在单线程事件循环模型之外的扩展性。这些机制可以帮助你优化应用的性能,特别是在处理CPU密集型或需要并行处理的任务时。
谨慎使用
并不是所有的场景都需要使用多线程。在大多数情况下,Nodejs 的非阻塞I/O和事件驱动模型已经提供了足够的性能。多线程应该作为一种优化手段,在必要时才使用。