线程和进程
线程
线程通信方式
- POSIX 信号量
- 互斥锁 (互斥量):独占方式访问关键代码段。
- 条件变量:某个共享数据达到某个值的时候,唤醒等待这个共享数据的线程。
线程的数据
生命周期
另外一副 Java 线程的状态转换图:
另外一副更为详细的:
何时可以响应中断
线程在运行态是不响应中断的。
状态 | 中断效果 | 描述 |
---|---|---|
NEW | 无 | |
RUNNABLE | 设置中断标志位 | 用户自己判断是否中断,以及如何处理 |
BLOCKED | 设置中断标志位 | 用户自己判断是否中断,以及如何处理 |
WAITING | 抛InterruptedException异常,并清空中断标志位 | |
TIMED_WAITING | 抛InterruptedException异常,并清空中断标志位 | |
TERMINATED | 无 |
自发性上下文切换
自发性上下文切换指线程由 Java 程序调用导致切出,在多线程编程中,执行调用以下方法或关键字,常常就会引发自发性上下文切换。sleep()
、wait()
、yield()
、join()
、park()
、synchronized
、lock
。
停止线程
(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_struct
、fs_struct
、sighand_struct
、signal_struct
、mm_struct
都复制一遍,从此父进程和子进程各用各的数据结构。而创建线程的话,调用的是系统调用 clone
,在 copy_process
函数里面, 五大结构仅仅是引用计数加一,也即线程共享进程的数据结构。
进程状态
一旦一个进程要结束,先进入的是 EXIT_ZOMBIE
状态,但是这个时候它的父进程还没有使用 wait()
等系统调用来获知它的终止信息,此时进程就成了僵尸进程。
进程通信方式
参考自 《Linux 高性能服务器编程》
- 信号
# 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
- 管道:可以在父子进程传递数据,利用的是
fork
调用之后两个管道文件描述符都保持打开。mkfifo hello
- 信号量:多个进程同时访问系统上某个资源,就需要考虑同步问题。
- 共享内存:最高效率的 IPC 机制,因为它在两个进程之间不需要传输任何数据。
- 消息队列:两个进程之间传递二进制块最简单有效的一种方式。
- 套接字
共享内存加信号量是常用的模式。这个需要牢记,常见到一些知名的以 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_FIFO
、SCHED_RR
、SCHED_DEADLINE
是实时进程的调度策略。
SCHED_FIFO
:交了相同钱的,先来先服务,但是有的加钱多,可以分配更高的优先级,也就是说,高优先级的进程可以抢占低优先级的进程,而相同优先级的进程,我们遵循先来先得。SCHED_RR
:轮流调度算法,采用时间片,相同优先级的任务当用完时间片会被放到队列尾部,以保证公平性,而高优先级的任务也是可以抢占低优先级的任务。SCHED_DEADLINE
:按照任务的deadline
进行调度的。当产生一个调度点的时候,DL 调度器总是选择其 deadline 距离当前时间点最近的那个任务,并调度它执行。
(2) 普通调度策略
对于普通进程的调度策略有,SCHED_NORMAL
、SCHED_BATCH
、SCHED_IDLE
。普通进程使用的调度策略是 fair_sched_class
,在 Linux 里面,实现了一个基于 CFS 的调度算法。CFS 全称 Completely Fair Scheduling,叫完全公平调度。
CFS 会为每一个进程安排一个虚拟运行时间 vruntime
,用红黑树进行排序。所有可运行的进程通过不断地插入操作最终都存储在以时间为顺序的红黑树中,vruntime 最小的在树的左侧,vruntime 最大的在树的右侧。
每个 CPU 都有自己的 struct rq
结构,其用于描述在此 CPU 上所运行的所有进程,其包括一个实时进程队列 rt_rq
和一个 CFS 运行队列 cfs_rq
,在调度时,调度器首先会先去实时进程队列找是否有实时进程需要运行,如果没有才会去 CFS 运行队列找是否有进程需要运行。
参考
- 《趣谈 Linux 操作系统》