线程模型的综述
ElvinMontgo
8年前
<p>本文首先介绍了一些线程基础,比如并发、并行、内存分配、系统调用、POSIX线程。接着通过strace分析了线程与进程的区别。最后以Android、Golang等线程模型进行了分析。</p> <h2>基础</h2> <h3>1. 什么是并发(Concurrent),什么是并行(Parallels)?</h3> <p>并发指同时进行多个计算任务。</p> <p><img src="http://static.open-open.com/lib/uploadImg/20160624/20160624094214_933.svg"></p> <p> </p> <p>并行指通过切换时间片模拟进行多个计算任务。</p> <p><img src="http://static.open-open.com/lib/uploadImg/20160624/20160624094215_224.svg"></p> <p> </p> <blockquote> <p>详细可以参考<a href="/misc/goto?guid=4959674789586747541">Difference between concurrent programming and parallel programming - stackoverflow</a></p> </blockquote> <h3>2. OS下的内存分配、用户区与内核区</h3> <p>在32位的Linux操作系统中,当一个进程启动后,将被分配4G的虚拟内存。内存可以分为两个空间,一个是用户空间(0~3G),另一个是内核空间(3G~4G)。其中用户空间就是代码运行的空间,比如堆栈、BSS(未初始化数据段)、DATA(已经初始化数据段)、TEXT(代码二进制段);而在内核空间中,是OS内核的映射,只有在执行syscall系统调用时,才能进行重写。</p> <p><img alt="线程模型的综述" src="https://simg.open-open.com/show/a1dd65185ea08011df8bbfad6711faf8.jpg"></p> <p>32 Bit OS Virtual Memory</p> <p>在用户态中,执行用户代码,比如直接运行C程序、或者运行JVM虚拟机等。</p> <p>在内核中,主要负责I/O(显示,层三以下的网络,FS),Memory(虚拟内存,页面替换/缓存), Process(信号、线程/进程管理,CPU调度)的管理,直接控制CPU、内存等硬件,权限(privilege)非常大;</p> <h3>3. 系统调用中断(SCI)</h3> <p>系统调用是用户与内核间的一个桩(stub),当在用户态执行高权限任务,需要通过系统调用切换入内核态去执行最底层任务。比如在C语言中调用<code>getTime()</code>时,大致流程如下</p> <pre> <code class="language-cpp">1. app method(User Application) | |调用stdlibc标准库 | 2. systemcall_stub(std libc) | |系统调用,进入内核态 | 3. system_call_table[call_number](Kernel) | |通过查表调用硬件函数 | 4. hardware_call(Kernel)</code></pre> <ol> <li>在App层面,开发者不需要自己写系统调用,系统会提供相关C标准库的SDK供开发者使用,比如开发者调用<code>getTime()</code>时,实际是使用了标准库的<code>time.h</code>头文件。</li> <li>代码在执行时,OS自动加载标准库。比如在android的bionic库中,实际执行getTime的系统调用是<a href="/misc/goto?guid=4959674789675761653">这里</a>的平台相关的汇编代码,将系统调用的ID、参数传入内核。</li> <li>内核通过系统调用ID进行表的索引,寻找真正的硬件调用函数</li> <li>进行硬件相关的调用</li> </ol> <blockquote> <p>在Mac下打开ActivityManager或者在Terminal中运行top,就可以显示地看到用户与系统的CPU占用</p> <img alt="线程模型的综述" src="https://simg.open-open.com/show/3ed8868cc977deae91979a4112a8e10f.png"> <p>User and Kernel CPU usage</p> </blockquote> <h3>4. POSIX线程模型</h3> <p>POSIX是IEEE P1003.1中的线程标准,目前所有的系统,甚至windows都支持POSIX。它提供了用户态下的线程编程接口,开发者在进行线程开发时,只用引用<code>pthread.h</code>头文件调用即可。程序在运行时通过系统调用,在内核中进行线程的实现。它有很多函数,比如create, exit, join, yield等,具体可以去各个平台下的libc源码/sdk中去看Header文件中方法的定义,比如android中使用biolibc中pthread.h的代码在<a href="/misc/goto?guid=4959674789757752425">这里</a>,这里的头文件是对内核线程的包装。</p> <h2>线程与进程的区别</h2> <p>以下特指32位下使用glibc的Linux系统中POSIX模型,即用户面模型</p> <p>本测试基于Ubuntu 14.04 i386</p> <h3>1. 测试代码设计</h3> <p>1.1. 线程测试代码</p> <pre> <code class="language-cpp">//modified from https://computing.llnl.gov/tutorials/pthreads/samples/hello.c //todo run: //clang -Wall -g pthread.c -o pthread.out -lpthread //strace -Cfo ./pthread.strace.log ./pthread.out #include <stdio.h> #include <stdlib.h> #include <pthread.h> void* PrintHello(void *threadid) { long tid; tid = (long)threadid; printf("Hello World! It's me, thread #%ld!\n", tid); pthread_exit(NULL); } int main(int argc, char *argv[]){ pthread_t thread; int rc = 0; long t = 0; printf("In main: creating thread %ld\n", t); rc = pthread_create(&thread, NULL, PrintHello, (void *)t); if (rc){ exit(-1); } }</code></pre> <p>1.2. 进程测试代码</p> <pre> <code class="language-cpp">//todo run: //clang -Wall -g fork.c -o fork.out //strace -Cfo ./fork.strace.log ./fork.out #include <unistd.h> int main(int argc, char *argv[]) { pid_t pid; pid = fork(); if(pid < 0){ return -1; } return 0; }</code></pre> <h3>2. 测试结果</h3> <p>调用<code>strace</code>命令后,结果如下</p> <p>2.1. 进程的strace路线如下</p> <pre> <code class="language-cpp">19948 execve("./fork.out", ["./fork.out"], [/* 68 vars */]) = 0 19948 brk(0) = 0x9bc000 19948 open("/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3 19948 read(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\320\37\2\0\0\0\0\0"..., 832) = 832 ..... 19948 clone(child_stack=0, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7f5adac4ca10) = 19949 .... 19949 +++ exited with 0 +++</code></pre> <p>2.2. 线程的strace路线如下</p> <pre> <code class="language-cpp">21958 execve("./pthread.out", ["./pthread.out"], [/* 68 vars */]) = 0 21958 open("/lib/x86_64-linux-gnu/libpthread.so.0", O_RDONLY|O_CLOEXEC) = 3 .... 21958 access("/etc/ld.so.nohwcap", F_OK) = -1 ENOENT (No such file or directory) 21958 open("/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3 21958 read(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\320\37\2\0\0\0\0\0"..., 832) = 832 21958 fstat(3, {st_mode=S_IFREG|0755, st_size=1845024, ...}) = 0 21958 mmap(NULL, 3953344, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7f34229e4000 .... 21958 clone(child_stack=0x7f34229e2fb0, flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|CLONE_SETTLS|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID, parent_tidptr=0x7f34229e39d0, tls=0x7f34229e3700, child_tidptr=0x7f34229e39d0) = 21959 .... 21958 +++ exited with 0 +++</code></pre> <h3>3. 测试结论</h3> <p>通过上述的调用栈分析,可以得知均是通过调用<code>x86_64-linux-gnu</code>下的libc库,接着通过systemcall函数<code>clone()</code>实现对内核Process的控制,主要区别在函数参数中FLAG上有所不同,clone_flag指定了可以共享的资源</p> <pre> <code class="language-cpp">//clone flag between thread and process //⚠️: 省略了`CLONE_`前缀 //进程的FLAG参数 flags=CHILD_CLEARTID|CHILD_SETTID|SIGCHLD //线程的FLAG参数 flags=VM|FS|FILES|SIGHAND|THREAD|SYSVSEM|SETTLS|PARENT_SETTID|CHILD_CLEARTID</code></pre> <p>通过对<code>clone</code>进行man<a href="/misc/goto?guid=4959674789838065675">查询</a>,</p> <p>进程的参数解释:</p> <ul> <li><code>CLONE_CHILD_CLEARTID</code>: Erase child thread ID at location ctid in child memory when the child exits, and do a wakeup on the futex at that address。</li> <li><code>CLONE_SETTLS</code>: thread local storage (TLS) area,注意这个不可移植</li> <li><code>CLONE_SIGHAND</code>: 共享signal handlers</li> </ul> <p>线程的一些参数解释:</p> <ul> <li><code>CLONE_VM</code>: the calling process and the child process run in the same memory space. (注意这里说的是<code>memory space</code>,指通过mmap()分配的内存。再多说一点,线程中的栈内存由<code>pthread_attr_t</code>属性中的<code>pthread_attr_setstacksize</code>函数实现,默认可能为8MB,当然在实际中我们使用栈内存大多都是几KB而已;堆内存是共享的,这里不讨论)</li> <li><code>CLONE_FS</code>: 共享文件系统,如下函数chroot(2), chdir(2), or umask(2)会被影响。</li> <li><code>CLONE_FILES</code>: 共享file descriptor table</li> <li><code>CLONE_SIGHAND</code>: 共享signal handlers</li> <li><code>CLONE_THREAD</code>: 共享thread group,即有相同的PID,独立的TID;</li> <li><code>CLONE_SYSVSEM</code>: 共享System V semaphore undo values列表,俺表示目前还不懂。</li> <li><code>CLONE_SETTLS</code>: thread local storage (TLS) area,注意这个不可移植</li> <li><code>CLONE_PARENT_SETTID</code>: Store child thread ID at location ptid in parent and child memory.</li> <li><code>CLONE_CHILD_CLEARTID</code>: Erase child thread ID at location ctid in child memory when the child exits, and do a wakeup on the futex at that address。</li> </ul> <p>接着结合一些教科书,可以得知</p> <table> <thead> <tr> <th> </th> <th>进程</th> <th>线程</th> </tr> </thead> <tbody> <tr> <td>用户层函数</td> <td>fork()</td> <td>pthread_create()</td> </tr> <tr> <td>内核实现</td> <td>clone()</td> <td>clone()</td> </tr> <tr> <td>内存</td> <td>新复制的内存(Copy-on-Write),独立4G(1G+3G)</td> <td>共享4G内存:其中8M左右的栈内存是私有的,可以通过参数决定;共享堆内存</td> </tr> <tr> <td>创建耗时</td> <td>复制的flag少,所以耗时多</td> <td>低</td> </tr> <tr> <td>上下文切换耗时</td> <td>switching the memory address</td> <td>几乎只有进出内核的损失</td> </tr> <tr> <td>内部通信</td> <td>IPC</td> <td>共享的内存区(更简单)</td> </tr> </tbody> </table> <h2>高级语言对内核线程的封装实现</h2> <p>除了通过POSIX标准外,高级语言也可以自己通过系统调用对内核的线程进行实现,主要有如下三种。</p> <h3>1. 纯内核线程实现(1:1)</h3> <p>此线程模型将内核线程与App线程一一对应,可以看作为一种简单的映射关系,这里的代表有POSIX线程模型(pthread),以及依赖pThread标准库的Java与Ruby(1.9+)线程模型。</p> <p>以在Android/ArtJvm下创建线程为例,具体实现调用栈如下</p> <pre> <code class="language-cpp">java.lang.Thread | POSIX thread(user mode){ 0. art.runtime.Thread::CreateNativeThread(cpp, in jvm) 1. pthread_create(pthread.h,标准库头文件) 2. bionic标准库下的so文件,进行SystemCall(libc) 3. 用户态陷入内核态 } | Kernal thread(kernal mode)</code></pre> <p>可以看出,在JVM下的实现主要是对POSIX线程的包装与映射,自己本身只是做了点微小的工作,特点如下:</p> <ol> <li>移植性较差,需要适配各种libc库,但是由于被OS直接管理,因此在分配任务上可以充分借用内核的高效调度,能够高效利用物理核并实现真正的并行。</li> <li>用户态与内核态切换有一定的消耗损失</li> </ol> <h3>2. 纯用户态实现(1:N)</h3> <p>将线程的调度在用户态实现,也称<code>green thread</code>,自己写调度算法,可以将一个native线程映射为多个app thread(这里也可以叫做线程包),这里的代表有Ruby(1.8-),Java等老版本,特点如下:</p> <ol> <li>移植性好,没有切换、映射到内核的损失</li> <li>需要自己维护Scheduler</li> <li>由于内核并不了解调度细节,很难进行多核利用</li> </ol> <h3>3. 混合实现(M:N)</h3> <p>可以同时运行M个kernel线程下管理N个app线程,比如golang。通过设置<code>GOMAXPROCS</code>个native线程,然后通过<code>go</code>关键词创建app线程,它的特点如下:</p> <ol> <li>调度器实现比较困难</li> <li>通过语法糖与管道简化了并发编程,切换损失低</li> <li>部分调度需要自己主动释放时间片</li> </ol> <pre> <code class="language-cpp">golang threading model(N) ↓ ↓ goroutine ↓ Kernal thread model(M)</code></pre> <blockquote> <p>详见<a href="/misc/goto?guid=4959674789974247285">libtask</a>与许式伟的《go语言编程》</p> </blockquote> <h2>总结</h2> <ol> <li>Concurrent是多个任务同时进行,而Parallels是分享时间片</li> <li>在启动一个程序后,将分配用户态与内核态任务,通过系统调用执行内核中的高权限任务</li> <li>POSIX是一种线程标准,或者是一种接口,由libc库实现</li> <li>线程与进程最大的区别在于<code>clone</code>操作时的flag不同,导致共享资源不同。最终创建、切换耗时不同;以及内存分配、内部通信复杂度不同。</li> <li>在Java中,<code>java.lang.Thread</code>与内核线程一一对应;在某些旧版语言中,实现了一个内核线程对应多个高层线程;在golang中,通过<code>goroutine</code>实现M个内核线程对应N个高层线程;</li> </ol> <h2>Ref</h2> <ol> <li><a href="/misc/goto?guid=4959674790048349009">https://www.zhihu.com/question/21461752</a></li> <li><a href="/misc/goto?guid=4959674790131138565">https://blog.codinghorror.com/understanding-user-and-kernel-mode/</a></li> <li><a href="/misc/goto?guid=4959674790209202714">http://stackoverflow.com/questions/1311402/differences-between-user-and-kernel-modes</a></li> <li><a href="/misc/goto?guid=4959674790295038326">https://zh.wikipedia.org/wiki/%E5%BF%99%E7%A2%8C%E7%AD%89%E5%BE%85</a></li> <li><a href="/misc/goto?guid=4959674790374813084">https://www.ibm.com/developerworks/cn/linux/l-system-calls/</a></li> </ol> <p><br> </p> <p>文/<a href="/misc/goto?guid=4959674790453950938">BlackSwift</a>(简书)<br> </p> <p> </p>