进程与线程
程序只是一组指令的有序集合,它本身没有任何运行的含义,它只是一个静态的实体。而进程则不同,它是程序在某个数据集上的执行。进程是一个动态的实体,它有自己的生命周期。它因创建而产生,因调度而运行,因等待资源或事件而被处于等待状态,因完成任务而被撤消。反映了一个程序在一定的数据集上运行的全部动态过程。
进程:是并发执行的程序在执行过程中分配和管理资源的基本单位,是一个动态概念,竞争计算机系统资源的基本单位。
线程:是进程的一个执行单元,是进程内科调度实体。比进程更小的独立运行的基本单位。线程也被称为轻量级进程。
一个程序至少一个进程,一个进程至少一个线程。
# 为什么会有线程?
每个进程都有自己的地址空间,即进程空间,在网络或多用户换机下,一个服务器通常需要接收大量不确定数量用户的并发请求,为每一个请求都创建一个进程显然行不通(系统开销大响应用户请求效率低),因此操作系统中线程概念被引进。
线程的执行过程是线性的,尽管中间会发生中断或者暂停,但是进程所拥有的资源只为改线状执行过程服务,一旦发生线程切换,这些资源需要被保护起来。
进程分为单线程进程和多线程进程,单线程进程宏观来看也是线性执行过程,微观上只有单一的执行过程。多线程进程宏观是线性的,微观上多个执行操作。
线程的改变只代表 CPU 的执行过程的改变,而没有发生进程所拥有的资源的变化。
# 进程线程的区别:
地址空间:同一进程的线程共享本进程的地址空间,而进程之间则是独立的地址空间。
资源拥有:同一进程内的线程共享本进程的资源如内存、I/O、cpu 等,但是进程之间的资源是独立的。一个进程崩溃后,在保护模式下不会对其他进程产生影响,但是一个线程崩溃整个进程都死掉。
所以多进程要比多线程健壮。
进程切换时,消耗的资源大,效率高。所以涉及到频繁的切换时,使用线程要好于进程。同样如果要求同时进行并且又要共享某些变量的并发操作,只能用线程不能用进程
执行过程:每个独立的进程有一个程序运行的入口、顺序执行序列和程序入口。但是线程不能独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制。
线程是处理器调度的基本单位,但是进程不是。
两者均可并发执行。
# 优缺点:
线程执行开销小,但是不利于资源的管理和保护。线程适合在 多 CPU 系统上运行。
进程执行开销大,但是能够很好的进行资源管理和保护。进程可以跨机器前移。
何时使用多进程,何时使用多线程?
对资源的管理和保护要求高,不限制开销和效率时,使用多进程。
要求效率高,频繁切换时,资源的保护管理要求不是很高时,使用多线程。
# 多进程
首先,先来讲一下 fork 之后,发生了什么事情。由 fork 创建的新进程被称为子进程(child process)。该函数被调用一次,但返回两次。两次返回的区别是子进程的返回值是 0,而父进程的返回值则是新进程(子进程)的进程 id。将子进程 id 返回给父进程的理由是:因为一个进程的子进程可以多于一个,没有一个函数使一个进程可以获得其所有子进程的进程 id。对子进程来说,之所以 fork 返回 0 给它,是因为它随时可以调用 getpid()来获取自己的 pid;也可以调用 getppid()来获取父进程的 id。(进程 id 0 总是由交换进程使用,所以一个子进程的进程 id 不可能为 0 )。
fork 之后,操作系统会复制一个与父进程完全相同的子进程,虽说是父子关系,但是在操作系统看来,他们更像兄弟关系,这 2 个进程共享代码空间,但是数据空间是互相独立的,子进程数据空间中的内容是父进程的完整拷贝,指令指针也完全相同,子进程拥有父进程当前运行到的位置(两进程的程序计数器 pc 值相同,也就是说,子进程是从 fork 返回处开始执行的),但有一点不同,如果 fork 成功,子进程中 fork 的返回值是 0,父进程中 fork 的返回值是子进程的进程号,如果 fork 不成功,父进程会返回错误。
可以这样想象,2 个进程一直同时运行,而且步调一致,在 fork 之后,他们分别作不同的工作,也就是分岔了。这也是 fork 为什么叫 fork 的原因。至于那一个最先运行,可能与操作系统(调度算法)有关,而且这个问题在实际应用中并不重要,如果需要父子进程协同,可以通过原语的办法解决。
# 通信概念
进程间的通信方式比较多,首先你需要理解下面这几个概念
- 竞态条件:即两个或多个线程同时对一共享数据进行修改,从而影响程序运行的正确性时,这种就被称为竞态条件(race condition)。
- 临界区:不仅共享资源会造成竞态条件,事实上共享文件、共享内存也会造成竞态条件、那么该如何避免呢?或许一句话可以概括说明
禁止一个或多个进程在同一时刻对共享资源(包括共享内存、共享文件等)进行读写。
换句话说,我们需要一种 互斥(mutual exclusion) 条件,这也就是说,如果一个进程在某种方式下使用共享变量和文件的话,除该进程之外的其他进程就禁止做这种事(访问统一资源)。一个好的解决方案,应该包含下面四种条件
- 任何时候两个进程不能同时处于临界区
- 不应对 CPU 的速度和数量做任何假设
- 位于临界区外的进程不得阻塞其他进程
- 不能使任何进程无限等待进入临界区
# 进程间常见的通信方式:
管道 pipe:管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间使用。进程的亲缘关系通常是指父子进程关系。
命名管道 FIFO:有名管道也是半双工的通信方式,但是它允许无亲缘关系进程间的通信。
消息队列 MessageQueue:消息队列是由消息的链表,存放在内核中并由消息队列标识符标识。消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。
共享存储 SharedMemory:共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的 IPC 方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与其他通信机制,如信号量,配合使用,来实现进程间的同步和通信。
信号量 Semaphore:信号量是一个计数器,可以用来控制多个进程对共享资源的访问。它常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段。
套接字 Socket:套接字也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同及其间的进程通信。
信号 ( sinal ) : 信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生。
# 线程间通信
- 两个进程间的两个线程通信,相当于进程间通信
- 一个进程中的两个线程间通信
通信方式:
- 互斥锁
线程抢占资源后上锁,该线程使用资源完毕后,锁的状态发生改变时再通知其它线程
while (抢资源 === 没抢到) {
// 本线程先去忙,请在这把锁的状态发生改变时再喊我(lock);
}
2
3
- mutex;
- lock_guard (在构造函数里加锁,在析构函数里解锁)
- unique_lock 自动加锁、解锁
读写锁 shared_lock (在执行加锁操作时需要额外表明读写意图,复数读者之间并不互斥,而写者则要求与任何人互斥)
信号量 (类计数器机制, 控制线程池线程数量)
条件变量 condition_variable (只要条件没有发生改变,就没有必要再去加锁、判断、条件不成立、解锁,完全可以让出 CPU 给别的线程。不过由于「条件是否达成」属于业务逻辑,操作系统没法管理,需要让能够作出这一改变的代码来手动通知,它解决的问题不是「互斥」,而是「等待」。 )
# Node 单进程单线程
NodeJs 是基于 Google Chrome 提供支持的 JavaScript V8 引擎 实现的 JavaScript 运行时环境. Node.js 应用程序运行于单个进程中,无需为每个请求创建新的线程. 在其标准库中提供了一组基于 libuv 的异步的 I/O 原生功能
libuv 的实现是一个很经典生产者-消费者模型。 libuv 在整个生命周期中,每一次循环都执行每个阶段(phase)维护的任务队列。逐个执行节点里的回调,在回调中,不断生产新的任务,从而不断驱动 libuv。
NodeJs 只在主线程上执行,它是单线程单进程模式. 这样可以减少进线程之间的切换导致的性能开销,并且不用考虑锁和线程池的问题。
严格意义上来说, NodeJS 是存在多线程的,基于 libuv 核心库维护任务队列的机制, 控制时机执行回调的形式实现的异步 I/O 功能.例如: Promise ,定时器,js 回调等. libuv 存在着一个 Event Loop,通过 Event Loop(事件循环)来切换实现类似多线程的效果。
# 单进程单线程基于事件驱动的问题
单进程单线程基于事件驱动的模式,使用单线程的优点是:避免内存开销和上下文切换的开销。
所有的请求都在单线程上执行的,其他的异步 IO 和事件驱动相关的线程是通过 libuv 中的事件循环来实现内部的线程池和线程调度的。可伸缩性比之前的都好,但是影响事件驱动服务模型性能的只有 CPU 的计算能力,只能使用单核的 CPU 来处理事件驱动,但是我们的计算机目前都是多核的,我们要如何使用多核 CPU 呢?如果我们使用多核 CPU 的话,那么 CPU 的计算能力就会得到一个很大的提升。
NodeJs 运行在单进程的主线程上,基于这种架构设计,为了扩展 NodeJS 的多核心利用能力, 只能实现多进程运行我们的程序,通过进程之间的通信机制,实现多核心多进程.
# Master-Worker
NodeJS 提供了 Child_process 和 Cluster 模块创建子进程,实现多进程和子进程的管理. 进程分为 Master(主进程)和 Worker(子进程)
master 进程负责调度或管理 worker 进程,那么 worker 进程负责具体的业务处理。
对于一个 B/S 架构的后端程序而言, master 就负责接受请求, 然后分发给 worker 进程进行对应的业务处理. 多个 worker 就相当于多台服务器工作.也就是一个集群. 同时 master 还负责监控 worker 的运行状态和管理操作。
# Worker 监听同一个端口
我们可以通过句柄的方式发送一个 server 对象。我们在 master 进程中创建一个 TCP 服务器,将服务器对象直接发送给 worker 进程,让 worker 进程去监听端口并处理请求。因此 master 进程和 worker 进程就会监听了相同的端口了。当我们的客户端发送请求时候,我们的 master 进程和 worker 进程都可以监听到
那么在这种模式下,主进程和 worker 进程都可以监听到相同的端口,当网络请求到来的时候,会进行抢占式调度,监听了 connection 事件的处理程序会抢占处理,只有一个 worker 进程会抢到链接然后进行服务,由于是抢占式调度,可以理解为谁先来谁先处理的模式,因此就不能保证每个 worker 进程都能负载均衡的问题。
以下是一段实例的代码
| ------ project
| |--- master.js // 主程序入口
| |--- worker.js // 子进程
| |--- client.js // 客户端
2
3
4
// master.js
const childProcess = require('child_process')
const cpus = require('os').cpus().length
const net = require('net')
const tcpServer = net.createServer()
tcpServer.listen(8089, (err) => {
if (err) return
for (let i = 0; i < cpus; i++) {
childProcess.fork('./worker.js').send('tcpServer', tcpServer)
}
})
2
3
4
5
6
7
8
9
10
11
12
13
14
// worker.js
process.on('message', (msg, tcpServer) => {
if (msg !== 'tcpServer') return
tcpServer.on('connection', (socket) => {
socket.end(`hello,i am ${process.pid}`)
})
})
2
3
4
5
6
7
8
// client.js
const net = require('net')
const cpus = require('os').cpus().length
for (let i = 0; i < cpus; ++i) {
net
.createConnection({
port: 8089,
host: '127.0.0.1'
})
.on('data', (d) => {
console.log(d.toString())
})
}
2
3
4
5
6
7
8
9
10
11
12
13
14
接下来用 node 运行 master.js ,然后在运行客户端程序 client.js ,我们可以看到输出:
hello,i am 5136
hello,i am 5136
hello,i am 5137
hello,i am 5135
hello,i am 5136
hello,i am 5133
hello,i am 5132
hello,i am 5137
hello,i am 5136
hello,i am 5135
hello,i am 5137
hello,i am 5133
2
3
4
5
6
7
8
9
10
11
12
可以看到的是 worker 们处理了请求 , 其中 pid :5136 的 worker 抢占到的处理较多.
# cluster 集群
上面我们使用 child_process 实现了 node 集群的组建, 工作 worker 重启, 监听一个端口等操作
实际上这也是 node cluster 集群的基础, cluster 集群封装子进程, 定义了一系列各平台中子进程的分发策略, 分发中使用了一些内置技巧防止工作进程任务过载。
第一种方法(也是除 Windows 外所有平台的默认方法)是循环法,由主进程负责监听端口,接收新连接后再将连接循环分发给工作进程,在分发中使用了一些内置技巧防止工作进程任务过载。
第二种方法是,主进程创建监听 socket 后发送给感兴趣的工作进程,由工作进程负责直接接收连接。
理论上第二种方法应该是效率最佳的。 但在实际情况下,由于操作系统调度机制的难以捉摸,会使分发变得不稳定。 可能会出现八个进程中有两个分担了 70% 的负载。
以下是一段消息系统的代码,它在主进程中对工作进程接收的 HTTP 请求数量保持计数:
const cluster = require('cluster')
const http = require('http')
if (cluster.isMaster) {
// 跟踪 http 请求。
let numReqs = 0
setInterval(() => {
console.log(`请求的数量 = ${numReqs}`)
}, 1000)
// 对请求计数。
function messageHandler(msg) {
if (msg.cmd && msg.cmd === 'notifyRequest') {
numReqs += 1
}
}
// 启动 worker 并监听包含 notifyRequest 的消息。
const numCPUs = require('os').cpus().length
for (let i = 0; i < numCPUs; i++) {
cluster.fork()
}
for (const id in cluster.workers) {
cluster.workers[id].on('message', messageHandler)
}
// 监听子进程退出事件后重启
cluster.on('exit', (worker, code, signal) => {
console.log(
'[Master] worker ' +
worker.process.pid +
' died with code:' +
code +
', and' +
signal
)
cluster.fork() // 重启子进程
})
} else {
// 工作进程有一个 http 服务器。
http
.Server((req, res) => {
res.writeHead(200)
res.end('你好世界\n')
// 通知主进程接收到了请求。
process.send({ cmd: 'notifyRequest' })
})
.listen(8000)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
如上: 我们可以通过 cluster.isMaster 判断是主进程还是 工作进程,然后在主进程监听 worker 的 message ,当收到 message 进行计数, 监听进程退出事件, 打印信息以及重启一个进程.