线程和进程

线程和进程

执行二进制文件

Linux 下二进制文件格式一般是 ELF(Executable and Linkable Format) 格式。

执行二进制文件的过程,简而言之就是load_elf_binary 加载二进制文件,然后 start_thread

其中 load_elf_binary 加载二进制文件中,很重要的一步就是建立内存映射

  • 设置内存映射区 mmap_base
  • 设置 vm_area_struct,栈底、栈顶
  • 将 ELF 文件中的代码映射到内存
  • 设置 vm_area_struct
  • 将依赖的 so 文件映射到内存的内存映射区域

最后行程下面的内存映射图:

线程

线程通信方式

  • POSIX 信号量
  • 互斥锁 (互斥量)独占方式访问关键代码段。
  • 条件变量:某个共享数据达到某个值的时候,唤醒等待这个共享数据的线程。

线程的数据

生命周期

另外一副 Java 线程的状态转换图

另外一副更为详细的

何时可以响应中断

线程在运行态是不响应中断的。

状态 中断效果 描述
NEW
RUNNABLE 设置中断标志位 用户自己判断是否中断,以及如何处理
BLOCKED 设置中断标志位 用户自己判断是否中断,以及如何处理
WAITING 抛InterruptedException异常,并清空中断标志位
TIMED_WAITING 抛InterruptedException异常,并清空中断标志位
TERMINATED

自发性上下文切换

自发性上下文切换指线程由 Java 程序调用导致切出,在多线程编程中,执行调用以下方法或关键字,常常就会引发自发性上下文切换。sleep()wait()yield()join()park()synchronizedlock

停止线程

(1) volatile 标志位

// set this to true to stop the thread
volatile boolean shutdown = false;
...
public void run() {
    while (!shutdown) {
        // continue processing
    }
}

(2) interrupt 中断

Thread t = new Thread(new Runnable(){
    @Override
    public void run() {
        while (!Thread.currentThread().isInterrupted()){
            // do stuff         
        }   
    }});
t.start();

// Sleep a second, and then interrupt
try {
    Thread.sleep(1000);
} catch (InterruptedException e) {
    
}

t.interrupt();

interrupt 的时候,如果线程正在 Thread.sleep(), otherThread.join(), object.wait(),那么这个线程应该会抛出 InterruptedException

sleep vs wait

  • wait 可以被另外一个线程通过调用 notify 唤醒,wait/notify 必须放到 synchronized 块中,wait 之后在 notify 之前操作系统不会给这个线程任何的时间片。
Object mon = ...;
synchronized (mon) {
    mon.wait();
} 
  • sleep 不能被唤醒 (除非调用 t.interrupt() 中断),sleep 的时候也不会自动释放锁sleep(n) 之后操作系统不会再调度这个线程,这段时间不会给它分配时间片

  • yield:操作系统可能也可能不给这个线程时间片。

线程的调用方式

函数的调用过程,A 调用 B、调用 C、调用 D,然后返回 C、返回 B、返回 A,这是一个后进先出的过程,可以利用来保存这个调用过程,如果去看函数调用汇编语言的代码,其实就是指令跳转,从代码的一个地方跳到另外一个地方。

在进程的内存空间里面,栈是一个从高地址到低地址,往下增长的结构,也就是上面是栈底,下面是栈顶,入栈和出栈的操作都是从下面的栈顶开始的。

我们来看 32 位操作系统的情况。在 CPU 里,ESP(Extended Stack Pointer)是栈顶指针寄存器,入栈操作 Push 和出栈操作 Pop 指令,会自动调整 ESP 的值。另外有一个寄存器 EBP(Extended Base Pointer),是栈基地址指针寄存器,指向当前栈帧的最底部。

例如,A 调用 B,A 的栈里面包含 A 函数的局部变量,然后是调用 B 的时候要传给它的参数,然后返回 A 的地址,这个地址也应该入栈,这就形成了 A 的栈帧。接下来就是 B 的栈帧部分了,先保存的是 A 栈帧的栈底位置,也就是 EBP。因为在 B 函数里面获取 A 传进来的参数,就是通过这个指针获取的,接下来保存的是 B 的局部变量等等。

当 B 返回的时候,返回值会保存在 EAX 寄存器中,从栈中弹出返回地址,将指令跳转回去,参数也从栈中弹出,然后继续执行 A。

进程

进程的数据结构

创建进程

fork 函数主要做了哪些事情:

  • 复制结构
// 复制结构
p = dup_task_struct(current, node);
// 重新设置进程运行的统计量
retval = copy_creds(p, clone_flags);
// 设置调度相关的变量
retval = sched_fork(clone_flags, p);
// 初始化与文件和文件系统相关的变量:
// 复制一个进程打开的文件信息
// 复制一个进程的目录信息
retval = copy_files(clone_flags, p);
retval = copy_fs(clone_flags, p);
// 初始化与信号相关的变量
init_sigpending(&p->pending);
retval = copy_sighand(clone_flags, p);
retval = copy_signal(clone_flags, p);
// 复制进程内存空间
retval = copy_mm(clone_flags, p);
  • 唤醒新进程
p->state = TASK_RUNNING;
enqueue_task_fair(); // 放入到 CFS 调度队列

创建进程的话,调用的系统调用是 fork,在 copy_process 函数里面,会将五大结构 files_structfs_structsighand_structsignal_structmm_struct 都复制一遍,从此父进程和子进程各用各的数据结构。而创建线程的话,调用的是系统调用 clone,在 copy_process 函数里面, 五大结构仅仅是引用计数加一,也即线程共享进程的数据结构。

进程状态

一旦一个进程要结束,先进入的是 EXIT_ZOMBIE 状态,但是这个时候它的父进程还没有使用 wait() 等系统调用来获知它的终止信息,此时进程就成了僵尸进程

进程通信方式

参考自 《Linux 高性能服务器编程》

  • 信号。这些信号是什么含义可以通过 man 7 signal 查看。
# kill -l
 1) SIGHUP       2) SIGINT       3) SIGQUIT      4) SIGILL       5) SIGTRAP
 6) SIGABRT      7) SIGBUS       8) SIGFPE       9) SIGKILL     10) SIGUSR1
11) SIGSEGV     12) SIGUSR2     13) SIGPIPE     14) SIGALRM     15) SIGTERM
...
  • 匿名管道tail -f log.txt | grep abc | sort。所谓的匿名管道,其实就是内核里面的一串缓存struct pipe_buffer *bufs
  • 管道:可以在父子进程传递数据,利用的是 fork 调用之后两个管道文件描述符都保持打开。mkfifo hello。所谓的命名管道,其实是也是内核里面的一串缓存
  • 信号量:多个进程同时访问系统上某个资源,就需要考虑同步问题。
  • 共享内存最高效率的 IPC 机制,因为它在两个进程之间不需要传输任何数据。为了访问共享内存,需要信号量进行保护。生产者和消费者都通过 shmat 将共享内存映射到各自的内存空间,在不同的进程里面映射的位置不同。
  • 消息队列:两个进程之间传递二进制块最简单有效的一种方式。一般不使用
  • 套接字

共享内存加信号量是常用的模式。这个需要牢记,常见到一些知名的以 C 语言开发的开源软件都会用到它。

进程调度算法

在 Linux 里面,进程大概可以分成两种:实时进程普通进程。在 task_struct 结构体中的 policy 是这个进程的调度策略:

unsigned int policy;

配合调度策略的,还有优先级,也在 task_struct 中。优先级其实就是一个数值,对于实时进程,优先级的范围是 0~99;对于普通进程,优先级的范围是 100~139数值越小,优先级越高。从这里可以看出,所有的实时进程都比普通进程优先级要高。

int prio, static_prio, normal_prio;
unsigned int rt_priority;

(1) 实时调度策略

其中 SCHED_FIFOSCHED_RRSCHED_DEADLINE 是实时进程的调度策略。

  • SCHED_FIFO:交了相同钱的,先来先服务,但是有的加钱多,可以分配更高的优先级,也就是说,高优先级的进程可以抢占低优先级的进程,而相同优先级的进程,我们遵循先来先得。
  • SCHED_RR:轮流调度算法,采用时间片,相同优先级的任务当用完时间片会被放到队列尾部,以保证公平性,而高优先级的任务也是可以抢占低优先级的任务。
  • SCHED_DEADLINE:按照任务的 deadline 进行调度的。当产生一个调度点的时候,DL 调度器总是选择其 deadline 距离当前时间点最近的那个任务,并调度它执行。

(2) 普通调度策略

对于普通进程的调度策略有,SCHED_NORMALSCHED_BATCHSCHED_IDLE。普通进程使用的调度策略是 fair_sched_class,在 Linux 里面,实现了一个基于 CFS 的调度算法。CFS 全称 Completely Fair Scheduling,叫完全公平调度

CFS 会为每一个进程安排一个虚拟运行时间 vruntime,用红黑树进行排序。所有可运行的进程通过不断地插入操作最终都存储在以时间为顺序的红黑树中,vruntime 最小的在树的左侧,vruntime 最大的在树的右侧。

每个 CPU 都有自己的 struct rq 结构,其用于描述在此 CPU 上所运行的所有进程,其包括一个实时进程队列 rt_rq 和一个 CFS 运行队列 cfs_rq,在调度时,调度器首先会先去实时进程队列找是否有实时进程需要运行,如果没有才会去 CFS 运行队列找是否有进程需要运行。

协程

如果想兼顾开发效率,又能保证高并发,协程就是最好的选择。它可以在保持异步化运行机制的同时,用同步方式写代码,这在实现高并发的同时,缩短了开发周期,是高性能服务未来的发展方向。

协程与异步编程相似的地方在于,它们必须使用非阻塞的系统调用与内核交互,把切换请求的权力牢牢掌握在用户态的代码中。但不同的地方在于,协程把异步化中的两段函数,封装为一个阻塞的协程函数。这个函数执行时,会使调用它的协程无感知地放弃执行权,由协程框架切换到其他就绪的协程继续执行。当这个函数的结果满足后,协程框架再选择合适的时机,切换回它所在的协程继续执行。

用户态的代码切换协程,与内核切换线程的原理是一样的。线程的栈有 8 MB,而协程栈的大小通常只有几十 KB。更低的内存占用空间为高并发提供了保证,毕竟十万并发请求,就意味着 10 万个协程。当然,栈缩小后,就尽量不要使用递归函数,也不能在栈中申请过多的内存,这是实现高并发必须付出的代价。

协程就是用户态的线程。然而,为了保证所有切换都在用户态进行,协程必须重新封装所有的阻塞系统调用,否则,一旦协程触发了线程切换,会导致这个线程进入休眠状态,进而其上的所有协程都得不到执行。比如,普通的 sleep 函数会让当前线程休眠,由内核来唤醒线程,而协程化改造后,sleep 只会让当前协程休眠,由协程框架在指定时间后唤醒协程。再比如,线程间的互斥锁是使用信号量实现的,而信号量也会导致线程休眠,协程化改造互斥锁后,同样由框架来协调、同步各协程的执行。

所以,协程的高性能,建立在切换必须由用户态代码完成之上,这要求协程生态是完整的,要尽量覆盖常见的组件。比如 MySQL 官方提供的客户端 SDK,它使用了阻塞 socket 做网络访问,会导致线程休眠,必须用非阻塞 socket 把 SDK 改造为协程函数后,才能在协程中使用。

参考

  • 《趣谈 Linux 操作系统》