关于进程和线程,在 Linux 中是一对儿很核心的概念。但是进程和线程到底有啥联系,又有啥区别,很多人还都没有搞清楚。
在网上对进程和线程的讨论中,很多都是聚集在这二位有啥不同。但事实在 Linux 上,进程和线程的相同点要远远大于不同点。在 Linux 下的线程甚至都被称为了轻量级进程。
我今天就给大家从 Linux 内核实现的角度,给大家深度对比下进程和线程。
在 Redis 6.0 以上的版本里,也开始支持使用多线程来提供核心服务,我们就以它为例。
在 Redis 主线程启动以后,会调用 initThreadedIO 来创建多个 io 线程。
redis 源码地址:https://github.com/redis/redis
//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;
}
}
创建线程具体调用的是 pthread_create 函数,pthread_create 是在 glibc 库中实现的。在 glibc 库中,pthread_create 函数的实现调用路径是 __pthread_create_2_1 -> create_thread。其中 create_thread 这个函数比较重要,它设置了创建线程时使用的各种 flag 标记。
//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);
...
}
在上面的代码中,传入参数中的各个 flag 标记是非常关键的。这里我们先知道一下传入了 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
...
在开始介绍线程的创建过程之前,先给大家看看内核中表示线程的数据结构。
开篇的时候我说了,进程和线程的相同点要远远大于不同点。主要依据就是在 Linux 中,无论进程还是线程,都是抽象成了 task 任务,在源码里都是用 task_struct 结构来实现的。
我们来看 task_struct 具体的定义,它位于 include/linux/sched.h
//file:include/linux/sched.h
struct task_struct {
//1.1 task状态
volatile long state;
//1.2 进程线程的pid
pid_t pid;
pid_t tgid;
//1.3 task树关系:父进程、子进程、兄弟进程
struct task_struct __rcu *parent;
struct list_head children;
struct list_head sibling;
struct task_struct *group_leader;
//1.4 task调度优先级
int prio, static_prio, normal_prio;
unsigned int rt_priority;
//1.5 地址空间
struct mm_struct *mm, *active_mm;
//1.6 文件系统信息(当前目录等)
struct fs_struct *fs;
//1.7 打开的文件信息
struct files_struct *files;
//1.8 namespaces
struct nsproxy *nsproxy;
...
}
这个数据结构已经在上一篇文章[《Linux进程是如何创建出来的?》] 中,我们详细介绍过了。
对于线程来讲,所有的字段都是和进程一样的(本来就是一个结构体来表示的)。包括状态、pid、task 树关系、地址空间、文件系统信息、打开的文件信息等等字段,线程也都有。
这也就是我前面说的,进程和线程的相同点要远远大于不同点,本质上是同一个东西,都是一个 task_struct !正因为进程线程如此之相像,所以在 Linux 下的线程还有另外一个名字,叫轻量级进程。至于说轻量在哪儿,稍后我们再说。
这里我们稍微说一下 pid 和 tgid 这两个字段。在 Linux 中,每一个 task_struct 都需要被唯一的标识,它的 pid 就是唯一标识号。
//file:include/linux/sched.h
struct task_struct {
......
pid_t pid;
pid_t tgid;
}
对于进程来说,这个 pid 就是我们平时常说的进程 pid。
对于线程来说,我们假如一个进程下创建了多个线程出来。那么每个线程的 pid 都是不同的。但是我们一般又需要记录线程是属于哪个进程的。这时候,tgid 就派上用场了,通过 tgid 字段来表示自己所归属的进程 ID。
这样内核通过 tgid 可以知道线程属于哪个进程。
要想知道进程和线程的区别到底在哪儿,我们从线程的创建过程来详细看一下。
在[《Linux进程是如何创建出来的?》] 一文中我们了解了进程的创建过程。事实上,进程线程创建的时候,使用的函数看起来不一样。但实际在底层实现上,最终都是使用同一个函数来实现的。
我们再简单回顾一下创建进程时 fork 系统调用的源码,fork 调用主要就是执行了 do_fork 函数。注意:fork 函数调用 do_fork 的传的参数分别是SIGCHLD、0,0,NULL,NULL。
//file:kernel/fork.c
SYSCALL_DEFINE0(fork)
{
return do_fork(SIGCHLD, 0, 0, NULL, NULL);
}
do_fork 函数又调用 copy_process 完成进程的创建。
//file:kernel/fork.c
long do_fork(...)
{
//复制一个 task_struct 出来
struct task_struct *p;
p = copy_process(clone_flags, ...);
...
}
我们在本文第一小节里介绍到 lib 库函数 pthread_create 会调用到 clone 系统调用,为其传入了一组 flag。
//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, ...);
...
}
好,我们找到 clone 系统调用的实现。
//file:kernel/fork.c
SYSCALL_DEFINE5(clone, ......)
{
return do_fork(clone_flags, newsp, 0, parent_tidptr, child_tidptr);
}
同样,do_fork 函数还是会执行到 copy_process 来完成实际的创建。
可见和创建进程时使用的 fork 系统调用相比,创建线程的 clone 系统调用几乎和 fork 差不多,也一样使用的是内核里的 do_fork 函数,最后走到 copy_process 来完整创建。
不过创建过程的区别是二者在调用 do_fork 时传入的 clone_flags 里的标记不一样!。
关于这些 flag 的含义,我们选几个关键的做一个简单的介绍,后面介绍 do_fork 细节的时候会再次涉及到。
这些 flag 会对 task_struct 产生啥影响,我们接着看接下来的内容。
在本节中我们以动态的视角来看一下线程的创建过程.
前面我们看到,进程和线程创建都是调用内核中的 do_fork 函数来执行的。在 do_fork 的实现中,核心是一个 copy_process 函数,它以拷贝父进程(线程)的方式来生成一个新的 task_struct 出来。
//file:kernel/fork.c
long do_fork(unsigned long clone_flags, ...)
{
//复制一个 task_struct 出来
struct task_struct *p;
p = copy_process(clone_flags, stack_start, stack_size,
child_tidptr, NULL, trace);
//子任务加入到就绪队列中去,等待调度器调度
wake_up_new_task(p);
...
}
在创建完毕后,调用 wake_up_new_task 将新创建的任务添加到就绪队列中,等待调度器调度执行。这个代码很长,我对其进行了一定程度的精简。
//file:kernel/fork.c
static struct task_struct *copy_process(...)
{
//4.1 复制进程 task_struct 结构体
struct task_struct *p;
p = dup_task_struct(current);
...
//4.2 拷贝 files_struct
retval = copy_files(clone_flags, p);
//4.3 拷贝 fs_struct
retval = copy_fs(clone_flags, p);
//4.4 拷贝 mm_struct
retval = copy_mm(clone_flags, p);
//4.5 拷贝进程的命名空间 nsproxy
retval = copy_namespaces(clone_flags, p);
//4.6 申请 pid && 设置进程号
pid = alloc_pid(p->nsproxy->pid_ns);
p->pid = pid_nr(pid);
p->tgid = p->pid;
if (clone_flags & CLONE_THREAD)
p->tgid = current->tgid;
......
}
可见,copy_process 先是复制了一个新的 task_struct 出来,然后调用 copy_xxx 系列的函数对 task_struct 中的各种核心对象进行拷贝处理,还申请了 pid 。接下来我们分小节来查看该函数的每一个细节。
注意一下,上面调用 dup_task_struct 时传入的参数是 current,它表示的是当前任务。在 dup_task_struct 里,会申请一个新的 task_struct 内核对象,然后将当前任务复制给它。需要注意的是,这次拷贝只会拷贝 task_struct 结构体本身,它内部包含的 mm_struct 等成员不会被复制。
我们来简单看下具体的代码。
//file:kernel/fork.c
static struct task_struct *dup_task_struct(struct task_struct *orig)
{
//申请 task_struct 内核对象
tsk = alloc_task_struct_node(node);
//复制 task_struct
err = arch_dup_task_struct(tsk, orig);
...
}
其中 alloc_task_struct_node 用于在 slab 内核内存管理区中申请一块内存出来。关于 slab 机制请参考- [内核内存管理]
//file:kernel/fork.c
static struct kmem_cache *task_struct_cachep;
static inline struct task_struct *alloc_task_struct_node(int node)
{
return kmem_cache_alloc_node(task_struct_cachep, GFP_KERNEL, node);
}
申请完内存后,调用 arch_dup_task_struct 进行内存拷贝。
//file:kernel/fork.c
int arch_dup_task_struct(struct task_struct *dst,
struct task_struct *src)
{
*dst = *src;
return 0;
}
我们先回忆一下前面的内容,创建线程调用 clone 系统调用的时候,传入了一堆的 flag,其中有一个就是 CLONE_FILES。如果传入了 CLONE_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 的。
再回忆一下创建线程的时候,传入的 flag 里也包括 CLONE_FS。如果指定了这个标志,就会复用当前进程的文件目录 - fs 成员。
对于创建进程来讲,没有传入这个标志,就会新创建一个 fs 出来。
好,我们继续看 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 并进行赋值拷贝。
创建线程的时候带了 CLONE_VM 标志,而创建进程的时候没带。接下来在 copy_mm 函数 中会根据是否有这个标志来决定是该和当前线程共享一份地址空间 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) {
atomic_inc(&oldmm->mm_users);
mm = oldmm;
goto good_mm;
}
mm = dup_mm(tsk);
good_mm:
return 0;
}
对于线程来讲,由于传入了 CLONE_VM 标记,所以不会申请新的 mm_struct 出来,而是共享其父进程的。
多线程程序中的所有线程都会共享其父进程的地址空间。
而对于多进程程序来说,每一个进程都有独立的 mm_struct(地址空间)。
因为在内核中线程和进程都是用 task_struct 来表示,只不过线程和进程的区别是会和创建它的父进程共享打开文件列表、目录信息、虚拟地址空间等数据结构,会更轻量一些。所以在 Linux 下的线程也叫轻量级进程。在打开文件列表、目录信息、内存虚拟地址空间中,内存虚拟地址空间是最重要的。因此区分一个 Task 任务该叫线程还是该叫进程,一般习惯上就看它是否有独立的地址空间。如果有,就叫做进程,没有,就叫做线程。
这里展开多说一句,对于内核任务来说,无论有多少个任务,其使用地址空间都是同一个。所以一般都叫内核线程,而不是内核进程。
创建线程的整个过程我们就介绍完了。回头总结一下,对于线程来讲,其地址空间 mm_struct、目录信息 fs_struct、打开文件列表 files_struct 都是和创建它的任务共享的。
但是对于进程来讲,地址空间 mm_struct、挂载点 fs_struct、打开文件列表 files_struct 都要是独立拥有的,都需要去申请内存并初始化它们。
总之,在 Linux 内核中并没有对线程做特殊处理,还是由 task_struct 来管理。从内核的角度看,用户态的线程本质上还是一个进程。只不过和普通进程比,稍微“轻量”了那么一些。
那么线程具体能轻量多少呢?我之前曾经做过一个进程和线程的上下文切换开销测试。进程的测试结果是一次上下文切换平均 2.7 - 5.48 us 之间。线程上下文切换是 3.8 us左右。总的来说,进程线程切换还是没差太多。参见[《进程/线程切换究竟需要多少开销?》]
本文由哈喽比特于2年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/--S94B3RswMdBKBh6uxt0w
京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。
日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。
据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。
今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。
日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。
近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。
据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。
9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...
9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。
据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。
特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。
据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。
近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。
据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。
9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。
《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。
近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。
社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”
2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。
罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。