简介
进程
进程作为能拥有资源和独立运行的基本单位,由于进程是一个资源的拥有者,因而在创建,撤销,切换中,系统必须为之付出较大的时空开销(资源的初始化和销毁)。正因如此,进程数目不宜过多,进程切换的频率也不宜过高,这也就限制了并发程度的进一步提高。不少学者发现能不能:将进程的上述两个属性分开,由操作系统分开处理亦即对于作为调度和分配的基本单位,不同时作为拥有资源的单位,以做到“轻装上阵”,而对于拥有资源的基本单位,又不对其进行频繁的切换。正是在这种思想的指导下:形成了线程的概念,把线程作为调度和分派的基本单位,而进程作为资源拥有的基本单位.把传统进程的两个属性分开,使线程基本上不拥有资源。
为什么进程的切换开销比线程大?
- 在创建或撤销进程时,系统都有为之创建和回收进程控制块,分配和回收资源,如内存地址空间和IO设备等,线程没有这些。
- 在进程切换时,涉及到当前进程CPU,环境的保存及新被调度运行进程的CPU环境的设置,而线程的切换则仅需保存和设置少量的寄存器内容。多个线程可以属于同一个进程并共享内存空间,因此多线程不需要创建新的虚拟内存空间,所以线程不需要内存管理单元处理上下文的切换。线程之间的通信基于共享的内存进行,因此也不需要维护文件描述符等。
线程
线程创建的成本
2018.7.7 补充:线程池的原理 我们首先来看,为什么说每次处理任务的时候再创建并销毁线程效率不高?
Thread t = new Thread(); // 此时只是在java 层面创建了一个对象
t.start()
native 的start 指令做了很多事情
JVM_ENTRY(void, JVM_StartThread(JNIEnv* env, jobject jthread))
JVMWrapper("JVM_StartThread");
JavaThread *native_thread = NULL;
{
MutexLocker mu(Threads_lock);
if (java_lang_Thread::is_stillborn(JNIHandles::resolve_non_null(jthread)) ||
java_lang_Thread::thread(JNIHandles::resolve_non_null(jthread)) != NULL) {
throw_illegal_thread_state = true;
} else {
jlong size = java_lang_Thread::stackSize(JNIHandles::resolve_non_null(jthread));
size_t sz = size > 0 ? (size_t) size : 0;
native_thread = new JavaThread(&thread_entry, sz);
if (native_thread->osthread() != NULL) {
// Note: the current thread is not being used within "prepare".
native_thread->prepare(jthread);
}
}
}
Thread::start(native_thread);
JVM_END
这段代码我也不懂,只是想表明, native 做了很多事情。包括但不限于:
- 创建一个native 线程
-
分配线程栈。jvm 参数
-Xss
,每个线程的堆栈大小,JDK5.0以后每个线程堆栈大小为1M,以前每个线程堆栈大小为256K.根据应用的线程所需内存大小进行调整.在相同物理内存下,减小这个值能生成更多的线程.但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000左右.小的应用,如果栈不是很深,128k应该是够用的,大的应用建议使用256k。这个选项对性能影响比较大,需要严格的测试(其它材料:64 位的 Linux 为每个线程的栈分配了 8MB 的内存,还预分配了 64MB 的内存作为堆内存池)。从这里可以看到两点:- 如果xss不显式设置, 新建线程时 os分配1m的空间绝对不是一个很easy的操作
- 线程数量 不准确的说 是一个内存耗费问题,在这个角度看,空间和算力有了一个对应关系。
- 将java 线程 关联到 native 线程上
从中可以看到:尽管java 线程和 os 线程具备一对一关系,但java 仍在jvm 层面上 为线程 维持了一些 数据结构。就好像 线程池中的线程 不是单纯的 new Thread
,java 线程 也不是 单纯的 os 线程。
The Go schedulerPOSIX线程API在很大程度上可以看做是对现有Unix进程模型的逻辑延伸,线程和进程有很多相似处。线程有自己的信号掩码(signal mask),可以分配与CPU关联,可以被放入cgroups,可以被查询使用了哪些资源。所有这些控制的特性都增加了开销。
如果没有这些微观细节,人就很难直观上 感受 线程池的好处。 空闲线程过多会有什么问题想额外强调是下面这几个内存占用,需要小心:
- ThreadLocal:业务代码是否使用了ThreadLocal?就算没有,Spring框架中也大量使用了ThreadLocal,你所在公司的框架可能也是一样。
- 局部变量:线程处于阻塞状态,肯定还有栈帧没有出栈,栈帧中有局部变量表,凡是被局部变量表引用的内存都不能回收。所以如果这个线程创建了比较大的局部变量,那么这一部分内存无法GC。
- TLAB机制:如果你的应用线程数处于高位,那么新的线程初始化可能因为Eden没有足够的空间分配TLAB而触发YoungGC。
2019.5.27补充:Linux内核基础知识
线程切换的成本
不仅创建线程的代价高,线程切换的开销也很高
- 线程切换只能在内核态完成,如果当前用户处于用户态,则必然引起用户态与内核态的切换。
- 涉及 16 个寄存器 PC、SP…等寄存器的刷新;
- 上下文切换,前面说线程信息需要用一个task_struct保存,线程切换时,必然需要将旧线程的task_struct从内核切出,将新线程的切入,带来上下文切换。除此之外,还需要切换寄存器、程序计数器、线程栈(包括操作栈、数据栈)等。2019.03.22补充:《反应式设计模式》尽管CPU已经越来越快,但更多的内部状态已经抵消了纯执行速度上带来的进步,使得上下文切换大约需要耗费1微秒的时间(数千个CPU时钟周期),这一点在二十年来几乎没有大的改进。
- 执行线程调度算法,线程1放弃cpu ==> 线程调度算法找下一个线程 ==> 线程2
- 缓存缺失,切换线程,需要执行新逻辑。如果二者的访问的地址空间不相近,则会引起缓存缺失。 PS “进程切换”的代价更大巨大,linux线程切换和进程切换当你改变虚拟内存空间的时候,处理的页表缓冲(processor’s Translation Lookaside Buffer (TLB))或者相当的神马东西会被全部刷新,这将导致内存的访问在一段时间内相当的低效。
协程
协程一般暗含一个常规操作:比如Go 语言的调度器通过使用与 CPU 数量相等的线程减少线程频繁切换的内存开销。
创建协程
为什么协程的开销比线程的开销小?
- Java Thread 和 kernel Thread 是1:1,Goroutine 是M:N ==> 执行体创建、切换过程不用“陷入”内核态。
- 只涉及到三个寄存器(PC / SP / DX)的值修改;
- JDK5以后Java Thread Stack默认为1M,Goroutine 的Stack初始化大小为2k。Go 语言的函数在运行的时候,会有一小块序曲代码,用来检查栈空间够不够用。如果不够用,就马上申请新的内存。需要注意的是,像这样的机制,必须有编译器的配合才行,编译器可以为每个函数生成这样的序曲代码。
- kernel 线程 对寄存器中的内容进行恢复还需要向操作系统申请或者销毁对应的资源
- Goroutine 调度的切换也不用陷入(trap)操作系统内核层完成
- 创建协程时,会从进程的堆中分配一段内存作为协程的栈。线程的栈有 8MB,而协程栈的大小通常只有几十 KB。而且,C 库内存池也不会为协程预分配内存,它感知不到协程的存在。
减少io操作引发的线程切换
切换请求是内核通过切换线程实现的,什么时候会切换线程呢?不只时间片用尽,当调用阻塞方法时,内核为了让 CPU 充分工作,也会切换到其他线程执行。一次上下文切换的成本在几十纳秒到几微秒间,当线程繁忙且数量众多时,这些切换会消耗绝大部分的 CPU 运算能力。PS:时间片用尽导致的切换是不可避免的,而是io导致的切换则可以规避。
下图描述了异步 IO 的非阻塞读和异步框架结合后,是如何切换请求的。
然而,写异步化代码很容易出错。因为所有阻塞函数,都需要通过非阻塞的系统调用拆分成两个函数。虽然这两个函数共同完成一个功能,但调用方式却不同。第一个函数由你显式调用,第二个函数则由多路复用机制调用。这种方式违反了软件工程的内聚性原则,函数间同步数据也更复杂。特别是条件分支众多、涉及大量系统调用时,异步化的改造工作会非常困难。
协程与异步编程相似的地方在于,它们必须使用非阻塞的系统调用与内核交互,把切换请求的权力牢牢掌握在用户态的代码中。但不同的地方在于,协程把异步化中的两段函数,封装为一个阻塞的协程函数。这个函数执行时,会使调用它的协程无感知地放弃执行权,由协程框架切换到其他就绪的协程继续执行。当这个函数的结果满足后,协程框架再选择合适的时机,切换回它所在的协程继续执行。
协程切换
每个线程有独立的栈,而栈既保留了变量的值,也保留了函数的调用关系、参数和返回值,CPU 中的栈寄存器 SP 指向了当前线程的栈,而指令寄存器 IP 保存着下一条要执行的指令地址。因此,从线程 1 切换到线程 2 时,首先要把 SP、IP 寄存器的值为线程 1 保存下来,再从内存中找出线程 2 上一次切换前保存好的寄存器值,写入 CPU 的寄存器,这样就完成了线程切换。用户态的代码切换协程,与内核切换线程的原理是一样的。
从协程 1 切换到协程 2 后的状态如下图所示:
每一次线程上下文的切换都需要消耗 ~1us 左右的时间。但是 Go 调度器对 Goroutine 的上下文切换 ~0.2us,除了减少上下文切换带来的开销,Golang 的调度器还能够更有效地利用 CPU 的缓存。
如何理解高并发中的协程协程之所以可以被暂停也可以继续,那么一定要记录下被暂停时的状态,也就是上下文,当继续运行的时候要恢复其上下文(状态),那么接下来很自然的一个问题就是,函数运行时的状态是什么?函数运行时所有的状态信息都位于函数运行时栈中。如果我们想暂停协程的运行就必须保存整个栈帧的数据,那么我们该将整个栈帧中的数据保存在哪里呢?堆区。进程地址空间最上层的栈区是用来保存函数栈帧的,只不过这些函数并不是运行在协程而是普通线程中的。
调度的成本
万字长文深入浅出 Golang RuntimeCPU 在时钟的驱动下, 根据 PC 寄存器从程序中取指令和操作数, 从 RAM 中取数据, 进行计算, 处理, 跳转, 驱动执行流往前. CPU 并不关注处理的是线程还是协程, 只需要设置 PC 寄存器, 设置栈指针等(这些称为上下文), 那么 CPU 就可以欢快的运行这个线程或者这个协程了.
调度的本质: 给CPU找活儿干 ==> PC 及 栈指针 能用即可 ==> 多任务 就得维护多份儿 PC及栈指针(栈空间) ==> PC(当前执行位置)和 栈空间 等聚合成一个数据结构,称为协程/线程/进程 ==> CPU层次的切换PC/SP 变成了切换 这个struct
真正运行的实体是CPU ,线程/协程是一个数据结构。线程的运行其实是被运行.阻塞其实是切换出调度队列, 不再去调度执行这个执行流. 其他执行流满足其条件, 便会把被移出调度队列的执行流重新放回调度队列.协程同理, 协程其实也是一个数据结构, 记录了要运行什么函数, 运行到哪里了.
许式伟:协程和线程的切换成本从单位时间成本来说,有一定优势但也不会特别大。主要少掉的代价是从用户态到内核态再回到用户态的成本。这种差异类似于系统调用和普通函数调用的差异。因为高性能服务器上 io 次数实在太多了,所以单位成本上能够少一点,积累起来也是很惊人的。
计算能力 | 需求 | 备注 | ||
---|---|---|---|---|
软硬件 | cpu | 创建线程的业务是无限的 | 用一个数据结构 表示和存放你要执行的任务/线程/进程,任尓干着急,我调度系统按既有的节奏来。 | |
java线程池 | 线程池管理的线程 | 要干的活儿是无限的 | 用一个runnable对象表示一个任务,线程池线程依次从队列中取出任务来执行 | 线程池管理的线程数可以扩大和缩小 |
goroutine | goroutine调度器管理的线程 | 要干的活儿是无限的 | 用协程表示一个任务,线程从各自的队列里取出任务执行 | A线程干完了,还可以偷B线程队列的活儿干 |