浏览器多进程和事件循环详解
参考链接
由于网上的关于浏览器进程和JS进程、JS线程和事件循环之间的关系模糊不清,这里主要是查阅资料进行详细汇总关于浏览器多进程和JS单线程的介绍:https://juejin.im/post/5a6547d0f265da3e283a1df7
关于Node事件循环的参考:https://juejin.im/post/5c337ae06fb9a049bc4cd218
关于浏览器时间循环的参考:https://juejin.im/post/5b8f76675188255c7c653811
关于宏任务微任务的详细介绍:https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/
多进程的浏览器
浏览器包含以下进程
- Browser进程,浏览器的主进程(负责协调,控制),只有一个
- 第三方插件进程
- GPU进程,最多一个,用于3D绘制
- 浏览器渲染进程(浏览器内核):默认每个tab页面一个进程,互不影响。主要作用为(页面渲染、脚本执行、事件处理)
文章图片
image 区分进程和线程
- 进程是一个工厂,工厂有它的独立资源
- 工厂之间相互独立
- 线程是工厂中的工人,多个工人协作完成任务
- 工厂内有一个或多个工人
- 工人之间共享空间
- 进程是系统分配的内存,是cpu资源分配的最小单位(独立的一块内存)
- 进程之间相互独立
- 多个线程在进程中协作完成任务
- 一个进程由一个或者多个线程组成
- 同一进程下的各个线程之间共享程序的内存空间(包括代码段、数据集、堆等)
- 线程是cpu调度的最小单位
通过右上角->更多工具->任务管理器可以查看到:
文章图片
image
可以查看到进程的类型:
文章图片
image 多进程优势
- 单个page 崩溃不会影响整个浏览器
- 避免第三方插件崩溃影响整个浏览器
- 多进程充分利用多核优势
- 方便使用沙盒模型隔离插件等进程,提高浏览器稳定性
页面的渲染,JS的执行,事件的循环,都在这个进程内进行。渲染进程的多线程
浏览器的渲染进程是多线程的。
- GUI渲染线程
- 负责渲染浏览器界面,解析HTML\CSS,构建DOM树和RenderObject树,布局和绘制等。
- 当界面需要重绘(Repaint)或由于某种操作引发回流(reflow)时,该线程就会执行
- GUI渲染线程与JS引擎线程是互斥的,当JS引擎执行时GUI线程会被挂起,GUI更新会被保存在一个队列重等到JS引擎空闲时立即被执行。
- JS引擎线程
- 也称为JS内核,负责处理JS脚本程序。例如V8引擎
- JS引擎线程负责解析JS脚本,运行代码
- JS引擎等待任务队列中的任务到来,然后加以处理,一个Tab页(渲染进程)无论什么时候都只有一个JS线程在运行JS程序
- GUI渲染线程和JS引擎线程互斥,所以如果JS执行时间过长,就会造成页面的渲染不连贯,阻塞页面渲染。
- 事件触发线程
- 用来控制事件循环
- 当JS引擎执行代码例如setTimeOut,或者其他线程(鼠标点击、AJAX异步请求等),会将对应任务添加到事件线程中。
- 当对应的事件符合触发条件被触发时,该线程会把事件添加到待处理队列的队尾,等待JS引擎的处理
- 由于JS单线程的关系,所以这些待处理队列中的事件都得排队等待JS引擎处理(JS引擎空闲时才会去执行 )
- 定时触发器线程
- setInterval与setTimeout所在线程
- 浏览器定时计数器并不是由js引擎计数的(因为js引擎是单线程的,如果处于阻塞线程状态就会影响计时的准确)
- 异步http请求线程
- XMLHTTPRequest在连接后新开一个线程请求
- 检测到状态变更时,如果有设置回调函数,异步线程产生状态变更事件,将这个回调再放入事件队列中,再由js引擎执行。
如果js执行时间过长,就会阻塞页面。
浏览器Browser进程和浏览器内核的通信过程
- Browser进程收到用户请求,首先需要获取页面内容,通过网络下载资源,随后将该任务通过RenderHost接口传递给渲染进程(内核)
- 渲染进程接收到消息,简单解释后,交给渲染线程,然后开始渲染
- 渲染线程接收到请求,加载网页并渲染网页,这其中可能需要Browser进程获取资源和需要GPU进程来帮助渲染
- 当然可能会有JS线程操作DOM(可能造成回流与重绘)
- 最后渲染进程将结果传递给Browser进程
- Browser进程接受到结果并将结果绘制出来
创建Worker时,js引擎向浏览器申请开一个子线程,子线程是浏览器开的,完全受主线程控制,而且不能操作DOM浏览器渲染流程
JS引擎线程与worker线程间通过特定的方式通信(postMessage API,需要通过序列化对象来与线程交互特定的数据)
所以如果有非常耗时的工作,可以单独开一个worker线程,这样里面不管如何翻天覆地都不会影响JS引擎主线程,只待计算出结果后,将结果通信给主线程即可。
输入URL,浏览器主进程接管,开启一个下载线程,进行HTTP请求,将响应内容转发给渲染进程。
文章图片
输入URL的浏览器行为 整个过程被分为9个小块:
- 提示卸载旧文档
- 重定向/卸载旧文档
- 应用缓存
- DNS域名解析
- TCP握手
- HTTP请求处理
- HTTP响应处理
- DOM处理
- 文档装载完成
- 解析HTML建立DOM
- 解析CSS建立CSSOM
- 合成render树(Layout/reflow)
- 绘制render树(paint)
- 浏览器将各层的信息发给GPU,GPU将各层合成(composite),显示在屏幕上
- DOMContentLoaded事件触发时,仅当DOM加载完成,不包括样式表,图片
- 当onload事件触发时,页面上所有的DOM,样式表,脚本,图片都已经加载完成。
回流(reflow)与重绘(repaint) 网页生成的时候,至少会渲染一次,用户访问的过程中,还会不断的重新渲染
以下三种情况,会导致网页重新渲染:
- 修改DOM
- 修改样式表
- 用户事件(比如鼠标悬停、页面滚动、输入框输入文字、改变窗口大小等等)
优化
提高网页性能,就是要降低重排和重绘的频率,尽量少触发重新渲染。
DOM变动和样式变动,都会触发重新渲染,但是浏览器已经很智能了,会尽量把所有的变动集中在一起,排成一个队列,然后一次性执行,避免多次渲染。例如:
div.style.color = 'blue';
div.style.marginTop = '30px';
元素有两个样式变动,但是浏览器只会触发一次重排和重绘
如果写的不好,就会触发两次重排和重绘
div.style.color = 'blue';
var margin = parseInt(div.style.marginTop);
div.style.marginTop = (margin + 10) + 'px';
//上面代码对div元素设置背景色后,第二行要求浏览器给出该元素的位置,所以浏览器不得不立即重排。
如果有以下读操作,都会引发浏览器立即重新渲染。
offsetTop/offsetLeft/offsetWidth/offsetHeight
scrollTop/scrollLeft/scrollWidth/scrollHeight
clientTop/clientLeft/clientWidth/clientHeight
getComputedStyle()
所以,从性能角度考虑,尽量不要把读操作和写操作放在一个语句里面
// bad
div.style.left = div.offsetLeft + 10 + "px";
div.style.top = div.offsetTop + 10 + "px";
// good
var left = div.offsetLeft;
//读
var top= div.offsetTop;
//读
div.style.left = left + 10 + "px";
//写
div.style.top = top + 10 + "px";
//写
一般规则
- 样式表越简单,重排和重绘就越快。
- 重排和重绘的DOM元素层级越高,成本就越高。
- table元素的重排与重绘成本,要高于div元素
1.DOM的多个读操作,应该放在一起,不要两个读操作之间,加入一个写操作2.如果某个样式是通过重排得到的,那么最好缓存结果,避免下一次用到的时候,浏览器又要重排3.不要一条条的改变样式,而要通过改变class或者csstext属性,一次性改变样式
// bad
var left = 10;
var top = 10;
el.style.left = left + "px";
el.style.top= top+ "px";
// good
el.className += " theclassname";
// good
el.style.cssText += ";
left: " + left + "px;
top: " + top + "px;
";
4.尽量使用离线DOM,而不是真实的网面DOM,来改变元素样式,比如操作Document Fragment对象,完成后再把这个对象加入DOM。再比如使用cloneNode()方法,在克隆的节点商进行操作,然后再用克隆的节点替换原始节点。5.先将元素设为dispaly:none(需要一次重排和重绘),然后对这个节点进行100次操作,最后再恢复显示(需要1次重排和重绘)。这样一来,就用两次重新渲染,取代了可能高达100次的重新渲染。6.position属性为absolute或者fixed的元素,重排的开销会比较小,因为不用考虑它对其他元素的影响,但会引起图层的重绘。7.只在必要的时候,才将元素的dispaly属性为可见,因为不可见的元素不影响重排和重绘,另外visibility:hidden的元素只对重绘有影响,不影响重排。8.使用虚拟DOM的脚本库9.使用 window.requestAnimationFrame()、window.requestIdleCallback() 这两个方法调节重新渲染10.使用硬件加速的复合图层
普通图层和复合图层
浏览器渲染的图层一般包括两大类:普通图层以及复合图层
普通文档流内可以理解为一个复合图层。其次absolute布局和fixed,虽然可以脱离普通文档流,但它仍然属于默认复合层
可以通过硬件加速的方式,声明一个新的复合图层,它会单独分配资源,这样一来,不管这个复合图层中怎么变化,也不会影响默认复合层里的回流重绘。将元素变成新的复合图层的方法(硬件加速):
GPU中各个复合图层是单独绘制的,所以互不影响
- css translate3d、translateZ
- opacity属性/过渡动画,执行过程中才会创建合成层,动画没有开始或者结束后元素还会回到之前的状态
- will-change属性,
asolute虽然可以脱离普通文档流,但仍属于默认复合图层,所以就算absolute的改变不会影响普通文档流中的render树(也就是不会引起其他元素的重排),但是仍然会引起重绘,由于浏览器绘制时,是整个复合图层绘制的,所以absolute仍然会影响整个复合图层的绘制。
硬件加速在新的复合图层,不互相影响。
同步任务和异步任务 js分为同步任务和异步任务
同步任务都在主线程上执行,形成一个执行栈
主线程运行时会产生执行栈,栈中的代码调用某些api时(例如DOM、ajax、计时器)有返回结果时,它们会在任务队列中添加任务。
事件触发线程管理着任务队列,只要异步任务有了运行结果,就在任务队列中放置一个任务。
一旦执行栈中所有同步任务执行完毕,系统就会读取任务队列,执行任务队列中的回调。
所以setTimeout推入的事件不一定能准时执行,因为可能在它推入到任务队列时,主线程还不空闲,正在执行其他代码,所以自然有误差。
setTimeout和setInterval语句的执行是在主线程中的,但他们的计时是由定时器线程控制的,计时结束后定时器线程会将回调推入到任务队列中
文章图片
image JS线程的内存空间 内存空间分为栈(stack)、堆(heap)、池(一般归类于栈)
6种基本类型Number、Boolean、String、Undefined、Null、Symbol(es6)是存储在栈内存中的
其他类型Function、Array、Object(实际上function和array都是基于Object实现的),存储在堆内存中,但地址存储在栈内存中
栈(stack)遵循的原则是“先进后出”,JS种的基本数据类型与指向对象的地址存放在栈内存中,此外还有一块栈内存用来执行JS主线程,执行栈(execution context stack)
通过以下两个例子可以了解到,浅拷贝即拷贝地址(拷贝对象属性的改变会引起源对象属性的改变),深拷贝则是新的一块内存
// 基本数据类型-栈内存
let a1 = 0;
// 基本数据类型-栈内存
let a2 = 'this is string';
// 基本数据类型-栈内存
let a3 = null;
// 对象的指针存放在栈内存中,指针指向的对象存放在堆内存中
let b = { m: 20 };
// 数组的指针存放在栈内存中,指针指向的数组存放在堆内存中
let c = [1, 2, 3];
复
文章图片
image 定时器线程 调用setTimeout或者setInterval后,定时器线程控制特定时间后,将任务添加到任务队列
setTimeout(function(){
console. log('hello!');
}, 0);
console. log('begin');
//先输出begin再hello
原因如下:
- 虽然代码的本意是0ms后就推入事件队列,但是W3C规定,setTimeout中低于4ms的时间间隔算为4ms。
- 即使真的0ms推入事件队列,也会先执行begin(可执行栈空闲后才会读取事件队列)
- setTimeout执行有误差
- setInterval每次都精确的推入一个事件
- 但setInterval有几个致命问题
- 累计效应,如果setInterval代码在再次添加到队列之前还没有完成执行,就会导致定时器代码连续运行好几次,而之间没有间隔。
- 浏览器最小化时,setInterval回掉函数放在队列中,等浏览器窗口再次打开时,一瞬间全部执行。
一般使用setTimeout或者requestAnimationFrame来替代setInterval
宏任务:macrotask,也叫做tasks,一些异步任务的回调回进入macro task queue,等待后续被调用
这些异步任务包括:
- setTimeout/setInterval
- setImmediate(Node独有)
- requestAnimationFrame(浏览器独有)
- I/O
- network events(http等协议请求)
- 用户交互事件(例如鼠标点击click事件、键盘输入keydown事件等)
- 文件相关操作(个人理解)
- UI rendering(浏览器独有)
这些异步任务包括:
- process.nextTick(Node独有,调用顺序在Promise之前)
- Promise
- Object.observe
- MutationObserver(用于监听DOM)
文章图片
image 从上图可以得出代码执行的具体流程:
- 执行全局的同步代码,同步代码中包含同步语句和异步语句(例如setTimeout和Promise)
- 同步代码执行完毕,执行栈Stack清空(代码往往由scirpt开始,为宏任务,所以下一步执行微任务)
- 从微任务队列microtask queue中取出位于队首的微任务回调,放入执行栈Stack中执行
- 继续将微任务队列中取于队首的任务放入执行栈中执行,直到微任务队列为空,如果在执行微任务回调的过程中,又产生了微任务,那么会被加入到微任务队列的队尾,也会在这个周期被调用执行
- 取出宏任务队列中处于队首的任务,放入执行栈中执行
- 执行完毕后,执行栈为空,继续重复3-5
- 宏任务队列macrotask queue一次只从队列中取出一个任务执行,执行完后就去执行微任务队列中的任务
- 微任务队列中所有的任务都会被依次取出来执行,直到微任务队列为空
- 渲染的时机处于微任务执行完毕之后,下一个宏任务执行之前
- 执行栈处于JS引擎线程中,跟普通栈一样,具有先进后出的原则,这样确保了函数内能够访问外部变量
- 宏任务和微任务的队列都存放的是宏任务和微任务的回调处理函数,这些队列的任务会被放入事件队列,事件队列是由事件触发线程管理的,事件队列是先进先出的
- 宏任务setInterval、setTimeout是经过计时器线程计数后,将回调放入宏任务队列中的。即setInterval、setTimeout是由计时器线程管理,回调函数由事件触发器线程管理
- 宏任务http请求是由异步HTTP请求线程处理的,处理完毕后将其回调函数放入宏任务队列中。
- 宏任务队列和微任务队列都属于事件队列
console.log(1);
setTimeout(() => {
//timeOutCallBack1
console.log(2);
Promise.resolve().then(() => {
//promiseCallBack2
console.log(3)
});
});
new Promise((resolve, reject) => {
console.log(4)
resolve(5)
}).then((data) => {
//promiseCallback1
console.log(data);
})setTimeout(() => {
//timeOutCallBack2
console.log(6);
})console.log(7);
//执行顺序
1、4、7、5、2、3、6
分析:
第一步:执行同步代码
输出1、4、7
macrotask queue:[timeOutCallBack1,timeOutCallBack2]
microtask queue:[promiseCallback1]
第二步:执行微任务队列promiseCallback1
输出1、4、7、5
stack queue:[promiseCallback1]
macrotask queue:[timeOutCallBack1,timeOutCallBack2]
microtask queue:[]
第三步:由于微任务队列为空,执行宏任务队列队首timeOutCallBack1
输出1、4、7、5、2
此时timeOutCallBack1宏任务中加入了promiseCallBack2微任务
stack queue:[timeOutCallBack1]
macrotask queue:[timeOutCallBack2]
microtask queue:[promiseCallBack2]
第四步:微任务队列不为空,执行微任务队列promiseCallBack2
输出1、4、7、5、2、3
stack queue:[promiseCallBack2]
macrotask queue:[timeOutCallBack2]
microtask queue:[]
第五步:微任务为空,执行宏任务
stack queue:[timeOutCallBack2]
macrotask queue:[]
microtask queue:[]
输出1、4、7、5、2、3、6
再参考如下
console.log(1);
setTimeout(() => {
console.log(2);
Promise.resolve().then(() => {
console.log(3)
});
});
new Promise((resolve, reject) => {
console.log(4)
resolve(5)
}).then((data) => {
console.log(data);
Promise.resolve().then(() => {
console.log(6)
}).then(() => {
console.log(7)setTimeout(() => {
console.log(8)
}, 0);
});
})setTimeout(() => {
console.log(9);
})console.log(10);
//1、4、10、5、6、7、2、3、9、8
上面的例子都是由Promise作为微任务的典型代表,这里看一下DOMObserver是否也属于微任务:
// Let's get hold of those elements
var outer = document.querySelector('.outer');
var inner = document.querySelector('.inner');
// Let's listen for attribute changes on the
// outer element
// 监听Outer元素的属性变动
new MutationObserver(function() {
//监听后回调
console.log('mutate');
}).observe(outer, {
attributes: true
});
// Here's a click listener…
function onClick() {
console.log('click');
setTimeout(function() {
console.log('timeout');
}, 0);
Promise.resolve().then(function() {
console.log('promise');
});
outer.setAttribute('data-random', Math.random());
}// …which we'll attach to both elements
inner.addEventListener('click', onClick);
outer.addEventListener('click', onClick);
假设我们点击了inner元素,会触发Inner和outer的点击事件(事件冒泡):
第一步:触发点击事件,推入inner、outer的onClick回调到宏任务队列
macrotasks queue:[innerOnclickCallback,outerOnclickCallback]
第二步:执行innerCallBack宏任务,此时将setTimeout回调推入宏任务队列(实际上是4ms之后),推入Promise回调进入微任务队列,修改outer属性,导致MutationObserver回调推入微任务队列(可以看出,要被推入宏、微任务队列,需要满足被触发的条件)
输出:click
execute stack:[innerOnclickCallback]
macrotasks queue:[outerOnclickCallback,innerSetTimeoutCallback]
microtasks queue:[innerPromiseCallback,innerObserverCallback]
第三部:宏任务执行完毕,执行微任务队列中的任务,直到微任务队列为空,也就是执行innerPromiseCallback,innerObserverCallback
输出:click promise mutate
execute stack:[innerPromiseCallback,innerObserverCallback] //这里为了方便简写,实际上是分开推入执行栈中执行的
macrotasks queue:[outerOnclickCallback,innerSetTimeoutCallback]
microtasks queue:[]
第四步:微任务队列为空,推入下一个宏任务outerOnclickCallback到执行栈中
输出:click promise mutate click
execute stack:[outerOnclickCallback]
macrotasks queue:[innerSetTimeoutCallback,outerSetTimeoutCallback]
microtasks queue:[outerPromiseCallback,outerObserverCallback]
后续如二三步
输出: click promise mutate click promise mutate click timeout timeout
不同浏览器可能有不同的输出,这是由于不同的浏览器和不同的版本可能会把promise和mutationObserver当做宏任务处理,这里以chrome为准
上面的例子是点击元素触发事件回调的,如果我们在代码对使用inner.click(),会同步的触发inner和outer的click回调,此时输出会变成:
click
click
promise
mutate
promise
timeout
timeout
这里mutate只输出一次是由于mutationObserver在监听outer时的回调函数还存在于microtask queue中,一次只能存在针对一个元素的mutationObserver回调
node下的Event Loop Node下的事件循环分为6个阶段(6个宏任务队列),它们会按照顺序反复运行,每当进入某一个阶段的时候,都会从对应的回调队列中取出函数去执行,当队列为空或者执行的回调函数数量达到系统设定的阈值,就会进入下一个阶段
文章图片
image node事件循环的顺序:
外部输入数据->轮询阶段(poll)->检查阶段(check)->关闭事件回调阶段(close callback)->定时器检测阶段->I/O事件回调阶段->闲置阶段(idle,prepare)->轮询阶段
- timers:setTimeout、setInterval的回调
- I/O:处理上一个轮循环中未执行的I/O回调
- idle,prepare:node内部调用
- poll:获取新的I/O事件,适当条件下将Node阻塞在这里
- check:执行setImmediate()回调
- close callbacks:执行socket的close事件回调
- timers阶段:timers会执行setTimeout和setInterval的回调,注意是全部执行完,直到timers队列为空,并且是由poll阶段控制的,同样的在Node中的事件也不是准确时间
- poll阶段:系统会做两件事
- 回到timers阶段执行回调
- 执行I/O回调
在进入该阶段时如果没有设定timer的话,会发生以下两件事情
- 如果 poll 队列不为空,会遍历回调队列并同步执行,直到队列为空或者达到系统限制
- 如果 poll 队列为空时,会有两件事发生
- 如果有setImmediate回调需要执行,poll阶段会停止并且进入到 check 阶段执行回调
- 如果没有setImmediate回调需要执行,会等待回调被加入到队列中并立即执行回调,这里同样会有个超时时间设置防止一直等待下去
console.log('start')
setTimeout(() => {
console.log('timer1')
Promise.resolve().then(function() {
console.log('promise1')
})
}, 0)
setTimeout(() => {
console.log('timer2')
Promise.resolve().then(function() {
console.log('promise2')
})
}, 0)
Promise.resolve().then(function() {
console.log('promise3')
})
console.log('end')
//start=>end=>promise3=>timer1=>timer2=>promise1=>promise2
这里和浏览器不一样的是,timer2在promise1之前输出,这是由于进入timers阶段时,会依次执行所有的timer任务,执行完毕后才会执行微任务
setTimeout和setImmediate:
- setTimeout在poll阶段空闲时间执行,且等待时间到达后执行,在timer阶段执行
- setImmediate在poll阶段完成时执行,即check阶段
const fs = require('fs')
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0)
setImmediate(() => {
console.log('immediate')
})
})
// immediate
// timeout
由于IO回调是在poll阶段执行,当回调执行完毕后队列为空,则往check阶段执行setImmediate回调,再执行timers阶段回调
【浏览器多进程和事件循环详解】微任务差别:在node环境下,process.nextTick的优先级高于Promise,也就是可以简单理解为:在宏任务结束后会先执行微任务队列中的nextTickQueue部分,然后才会执行微任务中的Promise部分
process.nextTick(function(){
console.log(7);
});
new Promise(function(resolve){
console.log(3);
resolve();
console.log(4);
}).then(function(){
console.log(5);
});
process.nextTick(function(){
console.log(8);
});
//输出3 4 7 8 5
node与浏览器事件循环差别
文章图片
image
setTimeout(()=>{
console.log('timer1')
Promise.resolve().then(function() {
console.log('promise1')
})
}, 0)
setTimeout(()=>{
console.log('timer2')
Promise.resolve().then(function() {
console.log('promise2')
})
}, 0)
- 浏览器端的运行结果是timer1 promise1 timer2 promise2
- node运行结果
- node11运行结果和浏览器一致,执行一个宏任务后则执行微任务队列
- node10以及之前的版本,看第一个定时器回调在执行栈时,第二个定时器回调是否在timer队列中
- 如果第二个定时器未在timer队列,则输出:timer1 promise1 timer2 promise2
- 如果第二个定时器在timer队列中,timer1 timer2 promise1 promise2,执行完timers队列后再执行微任务队列
- 从JS可以阻塞DOM树渲染可以看出,为了优化首屏加载速度,尽量避免将非必须的JS加载,把非必须的JS放在onload内。
- CSS也会阻塞渲染树生成,非首屏必要的CSS可以按需加载,CSS加载要放在头部
- 尽量使用离线DOM,减少渲染,例如document Fragment
- 不要一条条的改变样式,而要通过改变class或者csstext属性,一次性改变样式
- 由于渲染过程为执行栈->微任务->渲染,所以长耗时的JS任务会影响渲染,可以开启webWorker进行长耗时任务
- 使用setTimeout代替setInterval,但setTimeout的计时也是不准的(如果执行栈阻塞)
推荐阅读
- 放屁有这三个特征的,请注意啦!这说明你的身体毒素太多
- 爱就是希望你好好活着
- 昨夜小楼听风
- 知识
- 死结。
- 我从来不做坏事
- 烦恼和幸福
- 关于QueryWrapper|关于QueryWrapper,实现MybatisPlus多表关联查询方式
- Linux下面如何查看tomcat已经使用多少线程
- 说得清,说不清