1.3 线程的创建与异同汇总
前置:共享资源与 PID 管理
其中 mm_struct 是一个非常核心的数据结构,用户进程的虚拟地址空间就是用它来表示的。对于内核线程来讲,不需要虚拟地址空间,所以 mm 成员的值为 null。
另外还学到了内核是用 bitmap 来管理已使用和未使用的 PID 号的,这样做的好处是极大地节约了内存开销。而且由于数据存储的足够紧凑,遍历起来也是非常快。一方面原因是数据小,加载起来快。另一方面是会大大提高 CPU 缓存的命中率,访问非常快。
1.3 线程的创建
关于进程和线程,在 Linux 中是一对很核心的概念。但是进程和线程到底有啥联系,又有啥区别,很多人还没有搞清楚。在技术圈里对进程和线程的讨论中,很多都是聚集在这二者有啥不同。但事实在 Linux 上,进程和线程的相同点要远远大于不同点。在 Linux 下的线程甚至被称为 轻量级进程。
我今天就给大家从 Linux 内核实现的角度,给大家深度对比下进程和线程,让你对进程线程有最深入的理解和把握。在 Redis 6.0 以上的版本里,也开始支持使用多线程来提供核心服务,我们就以它为例。在 Redis 主线程启动以后,会调用 initThreadedIO 来创建多个 IO 线程。
// file:src/networking.c
void initThreadedIO(void) {
// 开始io线程的创建
for (int i = 0; i < server.io_threads_num; i++) {
pthread_t tid;
pthread_create(&tid, NULL, IOThreadMain, (void*)(long)i);
io_threads[i] = tid;
}
}可见 Redis 创建线程调用的是 pthread_create 函数,pthread_create 是在 glibc 库中封装实现的一个函数。在 glibc 库中,pthread_create 函数的实现调用路径是 __pthread_create_2_1 -> create_thread。其中 create_thread 这个函数比较重要,它设置了创建线程时使用的各种 flag 标记。
在上面的代码中,传入参数中的各个 flag 标记是非常关键的。在前面的小节中我们介绍了 flag 标记的含义,这里我们回忆下其中的三个:
- CLONE_VM:如果用了该标记,则新任务会共享虚拟地址空间(不创建新的)
- CLONE_FS:如果用了该标记,则新任务会共享文件系统信息
- CLONE_FILES:如果用了该标记,则新任务会共享打开文件列表
// file:nptl/sysdeps/pthread/createthread.c
static int create_thread (struct pthread *pd, ...)
{
int clone_flags = (CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGNAL
| CLONE_SETTLS | CLONE_PARENT_SETTID
| CLONE_CHILD_CLEARTID | CLONE_SYSVSEM
| 0);
int res = do_clone (pd, attr, clone_flags, start_thread,
STACK_VARIABLES_ARGS, 1);
...
}pthread_create 库调用 do_clone 时使用了 CLONE_VM、CLONE_FS、CLONE_FILES 等标记,接下来我们会看内核中如何针对这些参数做的特殊处理。
接下来的 do_clone 最终会调用一段汇编程序,在汇编里进入 clone 系统调用,之后会进入内核中进行处理。
// file:sysdeps/unix/sysv/linux/i386/clone.S
ENTRY (BP_SYM (__clone))
...
movl $SYS_ify(clone),%eax
...Redis 源码地址
Redis 源码地址:https://github.com/redis/redis
1.3.1 线程进程创建异同
在前面的小节中我们了解了进程的创建过程。事实上,进程线程创建的时候,使用的函数看起来不一样。但实际在底层实现上,最终都是使用同一个函数来实现的。
无论是创建子进程时调用的 fork,还是创建线程时调用的 glib 实现的 pthread_create,最后在内核中都是使用同一个函数 kernel_clone 来实现的。唯一的区别是在于传入的参数不同。
回忆下本章在前面介绍 fork 创建子进程的时候,传入的 flag 只有一个 SIGCHLD。
// file:kernel/fork.c
SYSCALL_DEFINE5(clone, ...)
{
struct kernel_clone_args args = {
.flags = (lower_32_bits(clone_flags) & ~CSIGNAL),
.pidfd = parent_tidptr,
.child_tid = child_tidptr,
.parent_tid = parent_tidptr,
.exit_signal = (lower_32_bits(clone_flags) & CSIGNAL),
.stack = newsp,
.tls = tls,
};
return kernel_clone(&args);
}而 pthread_create 传入到 clone 系统调用里的 flag 却包含了 CLONE_VM、CLONE_FS、CLONE_FILES 等标记。
理解进程和线程区别的关键也在于 CLONE_VM、CLONE_FS、CLONE_FILES 这几个 flag,理解了它们也会彻底理解了进程和线程的区别。接下来我们会在 fork 创建线程详细过程中介绍它们。
1.3.2 fork 创建线程详细过程
前面我们看到,6.1 版本的内核中,进程和线程创建都是调用内核中的 kernel_clone 函数来执行的。在 kernel_clone 的实现中,核心是一个 copy_process 函数,它以拷贝父进程(线程)的方式来生成一个新的 task_struct 出来。
在创建完毕后,调用 wake_up_new_task 将新创建的任务添加到就绪队列中,等待调度器调度执行。这个代码很长,我对其进行了一定程度的精简。
// file:kernel/fork.c
SYSCALL_DEFINE0(fork)
{
struct kernel_clone_args args = {
.exit_signal = SIGCHLD,
};
return kernel_clone(&args);
}
//file:nptl/sysdeps/pthread/createthread.c
static int create_thread (struct pthread *pd, ...)
{
int clone_flags = (CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGNAL
| CLONE_SETTLS | CLONE_PARENT_SETTID
| CLONE_CHILD_CLEARTID | CLONE_SYSVSEM
| 0);
int res = do_clone (...);
...
}
// file:kernel/fork.c
pid_t kernel_clone(struct kernel_clone_args *args)
{
...
// 复制一个 task_struct 出来
struct task_struct *p;
p = copy_process(NULL, trace, NUMA_NO_NODE, args);
// 子任务加入到就绪队列中去,等待调度器调度
wake_up_new_task(p);
...
}可见,copy_process 先是复制了一个新的 task_struct 出来,然后调用 copy_xxx 系列的函数对 task_struct 中的各种核心对象进行拷贝处理,还申请了 PID。接下来我们分小节来查看该函数的每一个细节。
1. 复制 task_struct 结构体
和创建进程一样,调用 dup_task_struct 时传入的参数是 current,它表示的是当前任务。在 dup_task_struct 里,会申请一个新的 task_struct 内核对象,然后将当前任务复制给它。这次拷贝只会拷贝 task_struct 结构体本身,它内部包含的 mm_struct 等成员不会被复制。
// file:kernel/fork.c
static struct task_struct *copy_process(...)
{
// 1 复制进程 task_struct 结构体
struct task_struct *p;
p = dup_task_struct(current, ...);
...
// 2 拷贝 files_struct
retval = copy_files(clone_flags, p);
// 3 拷贝 fs_struct
retval = copy_fs(clone_flags, p);
// 4 拷贝 mm_struct
retval = copy_mm(clone_flags, p);
// 5 拷贝进程的命名空间 nsproxy
retval = copy_namespaces(clone_flags, p);
// 6 申请 pid && 设置进程号
pid = alloc_pid(p->nsproxy->pid_ns_for_children, ...);
p->pid = pid_nr(pid);
if (clone_flags & CLONE_THREAD){
p->tgid = current->tgid;
} else {
p->tgid = p->pid;
}
...
}2. 拷贝打开文件列表
我们先回忆一下,创建线程调用 clone 系统调用的时候,传入了一堆的 flag,其中有一个就是 CLONE_FILES。如果传入了 CLONE_FILES 标记,就会复用当前进程的打开文件列表 - files 成员。
对于创建进程来讲,没有传入这个标志,就会新创建一个 files 成员出来。
子进程会创建新的 files,而线程却是完全复用当前线程的。我们到 copy_files 源码中来看一下。
//file:kernel/fork.c
static int copy_files(unsigned long clone_flags, struct task_struct *tsk)
{
struct files_struct *oldf, *newf;
oldf = current->files;
if (clone_flags & CLONE_FILES) {
atomic_inc(&oldf->count);
goto out;
}
newf = dup_fd(oldf, &error);
tsk->files = newf;
...
}从代码看出,如果指定了 CLONE_FILES(创建线程的时候),只是在原有的 files_struct 里面 +1 就算是完事了,指针不变,仍然是复用它创建的进程的 files_struct 对象。
这就是进程和线程的其中一个区别,对于进程来讲,每一个进程都需要独立的 files_struct。但是对于线程来讲,它是和创建它的线程复用 files_struct 的。
3. 拷贝文件目录信息
再回忆一下创建线程的时候,传入的 flag 里也包括 CLONE_FS。如果指定了这个标志,就会复用当前进程的文件目录 - fs 成员。
对于创建进程来讲,没有传入这个标志,就会新创建一个 fs 出来。
子进程会创建新的 fs_struct,而线程是完全复用当前线程的。好,我们再看 copy_fs 的实现。
//file:kernel/fork.c
static int copy_fs(unsigned long clone_flags, struct task_struct *tsk)
{
struct fs_struct *fs = current->fs;
if (clone_flags & CLONE_FS) {
fs->users++;
return 0;
}
tsk->fs = copy_fs_struct(fs);
return 0;
}和 copy_files 函数类似,在 copy_fs 中如果指定了 CLONE_FS(创建线程的时候),并没有真正申请独立的 fs_struct 出来,仅仅只是在原有的 fs 里的 users +1 就算是完事。
而在创建进程的时候,由于没有传递这个标志,会进入到 copy_fs_struct 函数中申请新的 fs_struct 并进行赋值拷贝。每一个进程都需要独立的 files。线程则是和创建它的线程复用同一套。
4. 拷贝内存地址空间
创建线程的时候带了 CLONE_VM 标志,而创建进程的时候没带。接下来在 copy_mm 函数中会根据是否有这个标志来决定是该和当前线程共享一份地址空间 mm_struct,还是创建一份新的。
对于线程来讲,由于传入了 CLONE_VM 标记,所以不会申请新的 mm_struct 出来,而是共享其父进程的。
//file:kernel/fork.c
static int copy_mm(unsigned long clone_flags, struct task_struct *tsk)
{
struct mm_struct *mm, *oldmm;
oldmm = current->mm;
if (clone_flags & CLONE_VM) {
mmget(oldmm);
mm = oldmm;
} else {
mm = dup_mm(tsk, current->mm);
...
}
tsk->mm = mm;
tsk->active_mm = mm;
return 0;
}多线程程序中的所有线程都会共享其父进程的地址空间。
而对于多进程程序来说,每一个进程都有独立的 mm_struct(地址空间)。
因为在内核中线程和进程都是用 task_struct 来表示,只不过线程和进程的区别是会和创建它的父进程共享打开文件列表、目录信息、虚拟地址空间等数据结构,会更轻量一些。所以在 Linux 下的线程也叫轻量级进程。
在打开文件列表、目录信息、内存虚拟地址空间中,内存虚拟地址空间是最重要的。因此区分一个 Task 任务该叫线程还是该叫进程,一般习惯上就看它是否有独立的地址空间。如果有,就叫做进程,没有,就叫做线程。
进程与线程最重要的区别
进程有独立的虚拟地址空间,而线程都是和别的某些任务复用同一套。
1.4 进程与线程的异同汇总
可见和创建进程时使用的 fork 系统调用相比,创建线程的 clone 系统调用几乎和 fork 差不多,也一样使用的是内核里的 kernel_clone 函数,最后走到 copy_process 来完整创建。不过创建过程的区别是二者在调用 kernel_clone 时传入的 clone_flags 里的标记不一样!!!
- 创建进程时的 flag:仅有一个
SIGCHLD - 创建线程时的 flag:包括
CLONE_VM、CLONE_FS、CLONE_FILES、CLONE_SIGNAL、CLONE_SETTLS、CLONE_PARENT_SETTID、CLONE_CHILD_CLEARTID、CLONE_SYSVSEM
关于这些 flag 的含义,创建线程时最主要的是下面这三个。
| Flag | 含义 |
|---|---|
CLONE_VM | 新 task 和父进程共享地址空间 |
CLONE_FS | 新 task 和父进程共享文件系统信息 |
CLONE_FILES | 新 task 和父进程共享文件描述符表 |
对于线程来讲,因为创建时使用了这些 flag,所以内核在创建线程时不再单独申请地址空间 mm_struct、目录信息 fs_struct、打开文件列表 files_struct。新线程的这些成员都是和创建它的进程共享。
但是对于进程来讲,地址空间 mm_struct、挂载点 fs_struct、打开文件列表 files_struct 都要是独立拥有的,都需要去申请内存并初始化它们。
总之,在 Linux 内核中并没有对线程做特殊处理,还是由 task_struct 来管理。从内核的角度看,线程本质上和进程没啥太大差别。只不过和普通进程比,稍微“轻量”了那么一些。总的来说,进程线程的相同点还是大于它们的不同点。
1.5 本章总结
在本章中,我们介绍了进程线程在内核中的定义,它就是 task_struct 结构体。在这个结构体中封装了进程所拥有的所有资源和属性,包括 PID、进程状态、父子关系、调度优先级、虚拟地址空间、文件系统信息、打开文件列表、命名空间等等。
接着我们还分别介绍了进程和线程的创建过程。进程创建调用 fork 系统调用就可以完成,但线程的创建是 glib 实现的 pthread_create,然后再调用 do_clone 系统调用完成的。虽然创建入口不同,传入的 flag 不同,但最终都是由同一个内核参数 kernel_clone 来实现的。
创建进程和线程相同的地方是都申请和初始化了一个 task_struct 内核对象。区别是根据传入的 flag 来有不同的行为。在创建进程的时候,虚拟地址空间、文件系统信息、打开文件列表等资源都是单独申请一份新的,而创建线程的时候这些资源都是直接复用创建它的进程的。
我们再来回顾下本章开篇处提到的几个问题:
2. Linux 中进程和线程的联系和区别究竟在哪儿?
联系:在 Linux 内核中,进程和线程都使用同一个数据结构
task_struct来表示;它们的创建最终都通过同一内核函数kernel_clone(通过copy_process)完成,唯一的区别是传入的clone_flags不同。从内核视角看,线程本质上就是一个“轻量级进程”。
区别:
- 进程拥有独立的虚拟地址空间(
mm_struct)、独立的文件系统信息(fs_struct)和独立的打开文件列表(files_struct)。- 线程则通过
CLONE_VM、CLONE_FS、CLONE_FILES等标志共享父进程的上述资源,不单独创建副本。- 区分一个任务称为“进程”还是“线程”的关键在于它是否拥有独立的虚拟地址空间:有独立地址空间的为进程,共享地址空间的为线程。
- 进程间相互隔离,线程间共享数据更方便但也需要同步机制保护。