线程模型的综述

线程   2016-06-24 09:41:30 发布
您的评价:
     
0.0
收藏     1收藏
文件夹
标签
(多个标签用逗号分隔)

本文首先介绍了一些线程基础,比如并发、并行、内存分配、系统调用、POSIX线程。接着通过strace分析了线程与进程的区别。最后以Android、Golang等线程模型进行了分析。

基础

1. 什么是并发(Concurrent),什么是并行(Parallels)?

并发指同时进行多个计算任务。

 

并行指通过切换时间片模拟进行多个计算任务。

 

详细可以参考Difference between concurrent programming and parallel programming - stackoverflow

2. OS下的内存分配、用户区与内核区

在32位的Linux操作系统中,当一个进程启动后,将被分配4G的虚拟内存。内存可以分为两个空间,一个是用户空间(0~3G),另一个是内核空间(3G~4G)。其中用户空间就是代码运行的空间,比如堆栈、BSS(未初始化数据段)、DATA(已经初始化数据段)、TEXT(代码二进制段);而在内核空间中,是OS内核的映射,只有在执行syscall系统调用时,才能进行重写。

线程模型的综述

32 Bit OS Virtual Memory

在用户态中,执行用户代码,比如直接运行C程序、或者运行JVM虚拟机等。

在内核中,主要负责I/O(显示,层三以下的网络,FS),Memory(虚拟内存,页面替换/缓存), Process(信号、线程/进程管理,CPU调度)的管理,直接控制CPU、内存等硬件,权限(privilege)非常大;

3. 系统调用中断(SCI)

系统调用是用户与内核间的一个桩(stub),当在用户态执行高权限任务,需要通过系统调用切换入内核态去执行最底层任务。比如在C语言中调用getTime()时,大致流程如下

1. app method(User Application)
    |
    |调用stdlibc标准库
    |
2. systemcall_stub(std libc)
    |
    |系统调用,进入内核态
    |
3. system_call_table[call_number](Kernel)
    |
    |通过查表调用硬件函数
    |
4. hardware_call(Kernel)
  1. 在App层面,开发者不需要自己写系统调用,系统会提供相关C标准库的SDK供开发者使用,比如开发者调用getTime()时,实际是使用了标准库的time.h头文件。
  2. 代码在执行时,OS自动加载标准库。比如在android的bionic库中,实际执行getTime的系统调用是这里的平台相关的汇编代码,将系统调用的ID、参数传入内核。
  3. 内核通过系统调用ID进行表的索引,寻找真正的硬件调用函数
  4. 进行硬件相关的调用

在Mac下打开ActivityManager或者在Terminal中运行top,就可以显示地看到用户与系统的CPU占用

线程模型的综述

User and Kernel CPU usage

4. POSIX线程模型

POSIX是IEEE P1003.1中的线程标准,目前所有的系统,甚至windows都支持POSIX。它提供了用户态下的线程编程接口,开发者在进行线程开发时,只用引用pthread.h头文件调用即可。程序在运行时通过系统调用,在内核中进行线程的实现。它有很多函数,比如create, exit, join, yield等,具体可以去各个平台下的libc源码/sdk中去看Header文件中方法的定义,比如android中使用biolibc中pthread.h的代码在这里,这里的头文件是对内核线程的包装。

线程与进程的区别

以下特指32位下使用glibc的Linux系统中POSIX模型,即用户面模型

本测试基于Ubuntu 14.04 i386

1. 测试代码设计

1.1. 线程测试代码

//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);
   }
}

1.2. 进程测试代码

//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;
}

2. 测试结果

调用strace命令后,结果如下

2.1. 进程的strace路线如下

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 +++

2.2. 线程的strace路线如下

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 +++

3. 测试结论

通过上述的调用栈分析,可以得知均是通过调用x86_64-linux-gnu下的libc库,接着通过systemcall函数clone()实现对内核Process的控制,主要区别在函数参数中FLAG上有所不同,clone_flag指定了可以共享的资源

//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

通过对clone进行man查询

进程的参数解释:

  • CLONE_CHILD_CLEARTID: Erase child thread ID at location ctid in child memory when the child exits, and do a wakeup on the futex at that address。
  • CLONE_SETTLS: thread local storage (TLS) area,注意这个不可移植
  • CLONE_SIGHAND: 共享signal handlers

线程的一些参数解释:

  • CLONE_VM: the calling process and the child process run in the same memory space. (注意这里说的是memory space,指通过mmap()分配的内存。再多说一点,线程中的栈内存由pthread_attr_t属性中的pthread_attr_setstacksize函数实现,默认可能为8MB,当然在实际中我们使用栈内存大多都是几KB而已;堆内存是共享的,这里不讨论)
  • CLONE_FS: 共享文件系统,如下函数chroot(2), chdir(2), or umask(2)会被影响。
  • CLONE_FILES: 共享file descriptor table
  • CLONE_SIGHAND: 共享signal handlers
  • CLONE_THREAD: 共享thread group,即有相同的PID,独立的TID;
  • CLONE_SYSVSEM: 共享System V semaphore undo values列表,俺表示目前还不懂。
  • CLONE_SETTLS: thread local storage (TLS) area,注意这个不可移植
  • CLONE_PARENT_SETTID: Store child thread ID at location ptid in parent and child memory.
  • CLONE_CHILD_CLEARTID: Erase child thread ID at location ctid in child memory when the child exits, and do a wakeup on the futex at that address。

接着结合一些教科书,可以得知

  进程 线程
用户层函数 fork() pthread_create()
内核实现 clone() clone()
内存 新复制的内存(Copy-on-Write),独立4G(1G+3G) 共享4G内存:其中8M左右的栈内存是私有的,可以通过参数决定;共享堆内存
创建耗时 复制的flag少,所以耗时多
上下文切换耗时 switching the memory address 几乎只有进出内核的损失
内部通信 IPC 共享的内存区(更简单)

高级语言对内核线程的封装实现

除了通过POSIX标准外,高级语言也可以自己通过系统调用对内核的线程进行实现,主要有如下三种。

1. 纯内核线程实现(1:1)

此线程模型将内核线程与App线程一一对应,可以看作为一种简单的映射关系,这里的代表有POSIX线程模型(pthread),以及依赖pThread标准库的Java与Ruby(1.9+)线程模型。

以在Android/ArtJvm下创建线程为例,具体实现调用栈如下

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)

可以看出,在JVM下的实现主要是对POSIX线程的包装与映射,自己本身只是做了点微小的工作,特点如下:

  1. 移植性较差,需要适配各种libc库,但是由于被OS直接管理,因此在分配任务上可以充分借用内核的高效调度,能够高效利用物理核并实现真正的并行。
  2. 用户态与内核态切换有一定的消耗损失

2. 纯用户态实现(1:N)

将线程的调度在用户态实现,也称green thread,自己写调度算法,可以将一个native线程映射为多个app thread(这里也可以叫做线程包),这里的代表有Ruby(1.8-),Java等老版本,特点如下:

  1. 移植性好,没有切换、映射到内核的损失
  2. 需要自己维护Scheduler
  3. 由于内核并不了解调度细节,很难进行多核利用

3. 混合实现(M:N)

可以同时运行M个kernel线程下管理N个app线程,比如golang。通过设置GOMAXPROCS个native线程,然后通过go关键词创建app线程,它的特点如下:

  1. 调度器实现比较困难
  2. 通过语法糖与管道简化了并发编程,切换损失低
  3. 部分调度需要自己主动释放时间片
golang threading model(N)
    ↓
    ↓ goroutine
    ↓
Kernal thread model(M)

详见libtask与许式伟的《go语言编程》

总结

  1. Concurrent是多个任务同时进行,而Parallels是分享时间片
  2. 在启动一个程序后,将分配用户态与内核态任务,通过系统调用执行内核中的高权限任务
  3. POSIX是一种线程标准,或者是一种接口,由libc库实现
  4. 线程与进程最大的区别在于clone操作时的flag不同,导致共享资源不同。最终创建、切换耗时不同;以及内存分配、内部通信复杂度不同。
  5. 在Java中,java.lang.Thread与内核线程一一对应;在某些旧版语言中,实现了一个内核线程对应多个高层线程;在golang中,通过goroutine实现M个内核线程对应N个高层线程;

Ref

  1. https://www.zhihu.com/question/21461752
  2. https://blog.codinghorror.com/understanding-user-and-kernel-mode/
  3. http://stackoverflow.com/questions/1311402/differences-between-user-and-kernel-modes
  4. https://zh.wikipedia.org/wiki/%E5%BF%99%E7%A2%8C%E7%AD%89%E5%BE%85
  5. https://www.ibm.com/developerworks/cn/linux/l-system-calls/


 

文/BlackSwift(简书)
 

 

扩展阅读

理解RxJava的线程模型
Java 多线程开发详解
Hadoop的Server及其线程模型分析
综述 href="/lib/view/open1361323461744.html">Java5,Java7实现的<线程池>综述
基于gevent和多线程模型的爬虫:Vulcan Spider

为您推荐

史上最全 前端开发面试问题及答案整理
Greys Java在线问题诊断工具
Bootstrap3 CSS样式基本用法总结
ADB 用法大全
jsoup 解析HTML信息

更多

线程
软件开发
相关文档  — 更多
相关经验  — 更多
相关讨论  — 更多