JS的多线程与线程池

线程与线程池原理全解析:打破单线程误区,从底层讲透

转载请注明出处:本文从底层原理出发,把JS多线程和线程池讲得明白,看完再也不会被"JS单线程"的说法误导。


一、先搞懂:JS到底是不是单线程?

几乎所有入门教程第一句都会告诉你:JS是单线程语言。但这句话其实只说了一半,很多人学了好几年都没搞懂另一半。

1.1 单线程到底指什么?

我们说JS单线程,指的是JS的主线程是单线程

  • 你写的JS业务代码,永远是在主线程上串行执行的
  • DOM操作、页面渲染、事件循环调度,都在主线程上跑
  • 同一时间主线程只能干一件事,前一个任务没执行完,后面的任务只能等着

这就是为什么你写个死循环,页面直接就卡了——主线程被占住了,渲染、交互都停了。

1.2 另一半真相:JS运行时是多线程的

但是!JS的运行环境(浏览器/Node.js)本身是多线程的,你每天都在用多线程,只是你自己不知道:

  • 你发个AJAX请求,网络请求是在专门的网络线程里跑的,不是主线程在等
  • 你读个本地文件,IO操作是在系统线程里跑的
  • 定时器计时、事件监听、图片解码,全都是在单独的线程里跑的

举个最通俗的例子:
你开了个奶茶店,你自己是收银员(主线程),负责点单收钱。你不可能点完单自己去做奶茶、煮珍珠、送外卖,那样后面的顾客全堵死了。所以你雇了几个后厨员工(其他线程)专门做这些事,你只负责点单,后厨做好了告诉你一声,你把奶茶给顾客就行。

这就是JS的线程模型:主线程当收银员做核心调度,其他脏活累活丢给别的线程干,干好了通知主线程。单线程的是你这个收银员,整个奶茶店(运行时)是多线程的。

1.3 为什么JS一开始要设计成主线程单线程?

很多人觉得单线程是JS的缺陷,其实这是故意设计的,最核心的原因有两个:

  1. 避免DOM操作冲突:如果JS是多线程,两个线程同时改DOM,一个要加节点一个要删节点,页面直接就乱了,还要加锁、同步,复杂度直接上天。单线程从根源上避免了这个问题。
  2. 降低语言门槛:JS刚出来的时候就是给网页做简单交互的,要是一开始就搞多线程,光线程同步、死锁这些概念就能劝退90%的开发者,根本不可能有今天的生态。

二、单线程的问题:为什么我们需要多线程?

单线程好是好,但有个致命问题:阻塞

主线程就像收银员,要是遇到个顾客点了100杯奶茶,你在那算钱算5分钟,后面所有顾客都得等着,店直接就堵死了。

对应到JS里,就是CPU密集型任务:比如100万次循环计算、大JSON解析、图片压缩、加密解密,这些任务放在主线程跑,直接就会导致:

  • 事件循环停转,所有异步回调都没法执行
  • 页面渲染卡住,白屏、掉帧
  • 用户点击、滑动完全没反应

这时候就需要用户可控的多线程了:浏览器有Web Worker,Node.js有Worker Threads,都是干这个的——把这些耗CPU的计算任务丢给单独的线程跑,主线程该干嘛干嘛,算完了把结果传回来就行。

Web Worker的本质是什么?

很多人以为Web Worker是什么黑科技,其实就是一个独立的JS运行环境:

  • 每个Worker都是操作系统分配的独立线程,和主线程完全并行
  • 有自己独立的V8实例、独立的内存空间、独立的事件循环
  • 和主线程完全隔离,不能共享变量,不能访问DOM、window这些主线程的东西
  • 唯一的通信方式就是postMessage发消息,底层把数据复制一份传过去

就相当于你给后厨单独开了个操作台,他在那自己做奶茶,做好了喊你一声,你递给他的材料都是复制一份给他,他那边改了不影响你这边的材料,从根源上避免冲突。

浏览器里除了最常用的普通Worker,还有两种特殊Worker:

  • SharedWorker:多个标签页可以共用同一个Worker,比如几个标签页共享一个WebSocket连接,不用每个页都开一个
  • ServiceWorker:后台常驻的线程,就算你把网页关了它还能跑,用来做离线缓存、消息推送这些

Node.js里的两套线程模型

Node.js比浏览器多一层,有两套完全独立的线程池,很多人搞混:

  1. libuv底层线程池:这个是Node自带的,你平时用的fs读文件、dns解析、crypto加密,全都是丢给这个线程池跑的,你写代码的时候感知不到,它默认开4个线程,专门处理系统层面的阻塞IO操作,不跑你的业务JS代码。
  2. Worker Threads:这个和浏览器的Web Worker是一个东西,专门给你跑业务JS里的CPU密集任务的,独立V8实例,和主线程并行,Node里还支持共享内存,通信比浏览器更方便。

一句话区分:libuv线程池是Node自带的后厨,专门做系统层面的杂活;Worker Threads是你自己雇的员工,专门帮你做业务里的重计算。


三、线程池原理:为什么不能想开多少线程就开多少?

很多人第一次接触多线程的时候会想:那我多开点线程不就快了?来一个任务开一个线程,岂不是美滋滋?

要是真这么干,你的程序直接就崩了。这就是为什么要有线程池。

3.1 开一个线程的成本比你想的大得多

你以为开线程就是new一下的事?实际上创建一个操作系统线程是非常重的操作:

  1. 内核开销:创建线程要进内核态,分配内核栈、线程ID、进程控制块,这个开销比普通函数调用大上千倍
  2. 内存开销:每个线程默认要占1M左右的栈内存,你开1000个线程就是1G内存,直接就OOM了
  3. CPU上下文切换开销:CPU核心数是有限的,你电脑一般也就4核8核,开几十上百个线程,CPU根本忙不过来,要不停在不同线程之间切换,每次切换都要保存寄存器、栈、程序计数器,切换开销占了一半以上的时候,反而比单线程还慢。

就像奶茶店,你本来就10平米的店,你雇20个后厨,人挤人转都转不开,不仅做不快,还容易出事,房租(内存)还贵死。

3.2 线程池到底是个什么东西?

线程池的思路特别简单:提前雇固定数量的员工,任务来了排队,谁闲下来谁做,做完一个接下一个,不随便招人也不随便裁人

本质上就是三个部分:

  1. 固定数量的工作线程:一开始就创建好N个线程,一直跑着循环等任务,不随便销毁
  2. 任务队列:所有要执行的任务先丢到队列里排队,先进先出
  3. 调度逻辑:哪个线程闲下来了,就从队列里拿第一个任务给它做,做完了再拿下一个

这样做的好处太明显了:

  • 不用频繁创建销毁线程,省了大量开销
  • 线程数量固定,不会把内存占满,也不会把CPU切换搞崩
  • 任务来了直接用空闲线程,不用等线程创建,响应更快
  • 所有线程统一管理,出问题好处理

3.3 线程池是怎么调度任务的?

工业级的线程池调度逻辑基本都是一样的,我们简单说下原理:

  1. 来新任务了,如果现在闲的线程够,直接给空闲线程做
  2. 如果线程都在忙,就把任务丢到队列里排队
  3. 如果队列也满了,就临时多开几个线程帮忙(最多开到设置的最大值)
  4. 如果线程数已经到最大值了还忙不过来,就触发拒绝策略(直接报错、丢任务、让提交任务的线程自己做等等)
  5. 等忙完了,临时开的线