用户级线程是如何调度/创建的,内核级线程是如何创建的?
- 2024-10-28 08:37:00
- admin 原创
- 71
问题描述:
如果这个问题很愚蠢,请原谅。我尝试在网上寻找答案很长时间,但找不到,所以我在这里提问。我正在学习线程,我一直在浏览这个链接和这个关于内核级和用户级线程的 2013 年 Linux Plumbers Conference 视频,据我所知,使用 pthreads 在用户空间中创建线程,而内核对此并不了解,只将其视为单个进程,不知道里面有多少个线程。在这种情况下,
谁来决定在进程获得的时间片内这些用户线程的调度,因为内核将其视为单个进程并且不知道线程,以及如何进行调度?
如果 pthreads 创建用户级线程,那么如果需要,如何从用户空间程序创建内核级或 OS 线程?
根据上面的链接,操作系统内核提供系统调用来创建和管理线程。那么
clone()
系统调用是创建内核级线程还是用户级线程?
+ 如果它创建了一个内核级线程,那么`strace`简单的pthreads 程序在执行时也会显示使用 clone(),但为什么它会被视为用户级线程?
+ 如果它不创建内核级线程,那么如何从用户空间程序创建内核线程?
根据链接,它说“它需要每个线程都有一个完整的线程控制块 (TCB) 来维护有关线程的信息。 因此,开销很大,并且内核复杂性增加。”,那么在内核级线程中,只有堆是共享的,其余的都是线程独有的?
编辑:
我问的是用户级线程创建及其调度,因为 这里引用了多对一模型,其中许多用户级线程映射到一个内核级线程,线程管理由线程库在用户空间中完成。我只看到使用 pthreads 的引用,但不确定它是创建用户级线程还是内核级线程。
解决方案 1:
这是以热门评论作为前提的。
您正在阅读的文档是通用的 [不是 Linux 特定的] 并且有点过时。更重要的是,它使用了不同的术语。我相信这就是造成混乱的根源。所以,请继续阅读...
它所谓的“用户级”线程就是我所说的 [过时的] LWP 线程。它所谓的“内核级”线程就是 Linux 中所谓的本机线程。在 Linux 下,所谓的“内核”线程是完全不同的东西 [见下文]。
使用 pthreads 在用户空间中创建线程,而内核对此并不了解,仅将其视为单个进程,不知道里面有多少个线程。
这就是在 (原生 posix 线程库) 出现之前用户空间线程的实现方式NPTL
。这也是 SunOS/Solaris 所称的LWP
轻量级进程。
有一个进程可以多路复用自身并创建线程。如果我没记错的话,它被称为线程主进程 [或类似名称]。内核不知道这一点。内核还不理解或不支持线程。
但是,因为这些“轻量级”线程是由基于用户空间的线程主机(又名“轻量级进程调度程序”)[只是一个特殊的用户程序/进程]中的代码切换的,所以它们切换上下文的速度非常慢。
此外,在“本机”线程出现之前,您可能有 10 个进程。每个进程获得 10% 的 CPU。如果其中一个进程是具有 10 个线程的 LWP,则这些线程必须共享这 10%,因此每个线程只能获得 1% 的 CPU。
所有这些都被内核调度程序所知道的“本机”线程所取代。这一转变是在 10-15 年前完成的。
现在,在上面的例子中,我们有 20 个线程/进程,每个线程/进程获得 5% 的 CPU。而且,上下文切换要快得多。
在本机线程下仍然可以拥有 LWP 系统,但是,现在,这是一种设计选择,而不是必需品。
此外,如果每个线程都“合作”,LWP 就能很好地工作。也就是说,每个线程循环都会定期显式调用“上下文切换”函数。它会主动放弃进程槽,以便另一个 LWP 可以运行。
但是,NPTL 之前的实现glibc
还必须 [强制] 抢占 LWP 线程(即实现时间分片)。我不记得使用了哪种机制,但这里有一个例子。线程主控必须设置一个闹钟,进入睡眠状态,然后唤醒,然后向活动线程发送信号。信号处理程序将影响上下文切换。这很混乱、丑陋,而且有点不可靠。
Joachim 提到的
pthread_create
函数创建了内核线程
从技术上来说,将其称为内核线程是错误的。pthread_create
创建一个本机线程。它在用户空间中运行,并与进程平等地争夺时间片。一旦创建,线程和进程之间就没有什么区别了。
主要区别在于进程有自己独特的地址空间。而线程是与同一线程组中的其他进程/线程共享地址空间的进程。
如果它不创建内核级线程,那么如何从用户空间程序创建内核线程?
内核线程不是用户空间线程、NPTL、本机线程或其他线程。它们由内核通过函数创建kernel_thread
。它们作为内核的一部分运行,与任何用户空间程序/进程/线程无关。它们对机器具有完全访问权限。设备、MMU 等。内核线程在最高特权级别运行:ring 0。它们还在内核的地址空间中运行,而不是在任何用户进程/线程的地址空间中运行。
用户空间程序/进程不得创建内核线程。请记住,它使用创建本机pthread_create
线程,并调用clone
系统调用来执行此操作。
线程对于执行操作很有用,甚至对于内核也是如此。因此,它会在各种线程中运行其部分代码。您可以通过执行来查看这些线程ps ax
。查看后您就会看到kthreadd, ksoftirqd, kworker, rcu_sched, rcu_bh, watchdog, migration
,等等。这些是内核线程,而不是程序/进程。
更新:
您提到内核不知道用户线程。
请记住,如上所述,有两个“时代”。
(1) 在内核获得线程支持之前(大约在 2004 年?)。这使用了线程主控(在这里,我将其称为 LWP 调度程序)。内核只有系统fork
调用。
(2) 此后的所有内核都理解线程。没有线程主控,但是,我们有pthreads
和clone
系统调用。现在,fork
实现为clone
。clone
类似于fork
,但需要一些参数。值得注意的是,一个flags
参数和一个child_stack
参数。
更多信息如下...
那么,用户级线程如何可能拥有单独的堆栈?
处理器堆栈没有什么“魔力”。我将主要讨论 x86,但这适用于任何架构,甚至那些没有堆栈寄存器的架构(例如 1970 年代的 IBM 大型机,如 IBM System 370)
在 x86 下,堆栈指针是%rsp
。x86 有push
和pop
指令。我们用它们来保存和恢复事物:push %rcx
和 [稍后] pop %rcx
。
但是,假设 x86没有%rsp
或指令?我们还能有push/pop
堆栈吗?当然,按照惯例。我们(作为程序员)同意(例如)%rbx
是堆栈指针。
在这种情况下,“推送”%rcx
将是[使用 AT&T 汇编程序]:
subq $8,%rbx
movq %rcx,0(%rbx)
并且,“流行”的%rcx
将是:
movq 0(%rbx),%rcx
addq $8,%rbx
为了方便理解,我将使用 C“伪代码”。以下是上述 push/pop 的伪代码:
// push %ecx
%rbx -= 8;
0(%rbx) = %ecx;
// pop %ecx
%ecx = 0(%rbx);
%rbx += 8;
要创建线程,LWP 调度程序必须使用 创建一个堆栈区域malloc
。然后,它必须将此指针保存在每个线程结构中,然后启动子 LWP。实际代码有点棘手,假设我们有一个LWP_create
类似于 的(例如)函数pthread_create
:
typedef void * (*LWP_func)(void *);
// per-thread control
typedef struct tsk tsk_t;
struct tsk {
tsk_t *tsk_next; //
tsk_t *tsk_prev; //
void *tsk_stack; // stack base
u64 tsk_regsave[16];
};
// list of tasks
typedef struct tsklist tsklist_t;
struct tsklist {
tsk_t *tsk_next; //
tsk_t *tsk_prev; //
};
tsklist_t tsklist; // list of tasks
tsk_t *tskcur; // current thread
// LWP_switch -- switch from one task to another
void
LWP_switch(tsk_t *to)
{
// NOTE: we use (i.e.) burn register values as we do our work. in a real
// implementation, we'd have to push/pop these in a special way. so, just
// pretend that we do that ...
// save all registers into tskcur->tsk_regsave
tskcur->tsk_regsave[RAX] = %rax;
// ...
tskcur = to;
// restore most registers from tskcur->tsk_regsave
%rax = tskcur->tsk_regsave[RAX];
// ...
// set stack pointer to new task's stack
%rsp = tskcur->tsk_regsave[RSP];
// set resume address for task
push(%rsp,tskcur->tsk_regsave[RIP]);
// issue "ret" instruction
ret();
}
// LWP_create -- start a new LWP
tsk_t *
LWP_create(LWP_func start_routine,void *arg)
{
tsk_t *tsknew;
// get per-thread struct for new task
tsknew = calloc(1,sizeof(tsk_t));
append_to_tsklist(tsknew);
// get new task's stack
tsknew->tsk_stack = malloc(0x100000)
tsknew->tsk_regsave[RSP] = tsknew->tsk_stack;
// give task its argument
tsknew->tsk_regsave[RDI] = arg;
// switch to new task
LWP_switch(tsknew);
return tsknew;
}
// LWP_destroy -- destroy an LWP
void
LWP_destroy(tsk_t *tsk)
{
// free the task's stack
free(tsk->tsk_stack);
remove_from_tsklist(tsk);
// free per-thread struct for dead task
free(tsk);
}
对于理解线程的内核,我们使用pthread_create
和clone
,但我们仍然必须创建新线程的堆栈。内核不会为新线程创建/分配堆栈。clone
系统调用接受一个child_stack
参数。因此,pthread_create
必须为新线程分配一个堆栈并将其传递给clone
:
// pthread_create -- start a new native thread
tsk_t *
pthread_create(LWP_func start_routine,void *arg)
{
tsk_t *tsknew;
// get per-thread struct for new task
tsknew = calloc(1,sizeof(tsk_t));
append_to_tsklist(tsknew);
// get new task's stack
tsknew->tsk_stack = malloc(0x100000)
// start up thread
clone(start_routine,tsknew->tsk_stack,CLONE_THREAD,arg);
return tsknew;
}
// pthread_join -- destroy an LWP
void
pthread_join(tsk_t *tsk)
{
// wait for thread to die ...
// free the task's stack
free(tsk->tsk_stack);
remove_from_tsklist(tsk);
// free per-thread struct for dead task
free(tsk);
}
内核只会为进程或主线程分配其初始堆栈,通常位于高内存地址。因此,如果进程不使用线程,通常它只会使用预先分配的堆栈。
但是,如果创建了线程(无论是LWP 还是本机线程),则启动进程/线程必须使用 为拟议的线程预分配区域malloc
。旁注:使用malloc
是正常方式,但线程创建者可以拥有一个大型全局内存池:char stack_area[MAXTASK][0x100000];
如果它希望这样做的话。
如果我们有一个不使用[任何类型]线程的普通程序,它可能希望“覆盖”给定的默认堆栈。
如果该进程正在执行大型递归函数,则它可能决定使用malloc
上述汇编程序技巧来创建更大的堆栈。
在这里查看我的答案:用户定义堆栈和内置堆栈在内存使用方面有什么区别?
解决方案 2:
用户级线程通常是协程,形式多种多样。在用户模式下,在执行流之间切换上下文,无需内核参与。从内核的角度来看,所有线程都是一个线程。线程实际执行的操作由用户模式控制,并且用户模式可以暂停、切换、恢复逻辑执行流(即协程)。这一切都发生在为实际线程安排的量子期间。内核可以并且会毫不留情地中断实际线程(内核线程),并将处理器的控制权交给另一个线程。
用户模式协程需要协作式多任务处理。用户模式线程必须定期将控制权移交给其他用户模式线程(基本上,执行将上下文更改为新的用户模式线程,而内核线程却不会注意到任何事情)。通常情况下,代码比内核更清楚何时需要释放控制权。编码不当的协程可能会窃取控制权,并使所有其他协程陷入困境。
使用过的历史实现setcontext
,但现在已弃用。Boost.context提供了它的替代品,但不是完全可移植的:
Boost.Context 是一个基础库,可在单个线程上提供一种协作式多任务处理。通过提供当前线程中当前执行状态的抽象,包括堆栈(带有局部变量)和堆栈指针、所有寄存器和 CPU 标志以及指令指针,execution_context 表示应用程序执行路径中的特定点。
毫不奇怪,Boost.coroutine是基于 Boost.context 的。
Windows 提供的Fibers。.Net运行时有 Tasks 和 async/await。
解决方案 3:
LinuxThreads 遵循所谓的“一对一”模型:每个线程实际上是内核中的一个独立进程。内核调度程序负责调度线程,就像调度常规进程一样。线程是通过 Linux clone() 系统调用创建的,它是 fork() 的泛化,允许新进程共享父进程的内存空间、文件描述符和信号处理程序。
来源 - Xavier Leroy(LinuxThreads 的创建者)的采访
http://pauillac.inria.fr/~xleroy/linuxthreads/faq.html#K
- 2024年20款好用的项目管理软件推荐,项目管理提效的20个工具和技巧
- 2024年开源项目管理软件有哪些?推荐5款好用的项目管理工具
- 项目管理软件有哪些?推荐7款超好用的项目管理工具
- 项目管理软件哪个最好用?盘点推荐5款好用的项目管理工具
- 项目管理软件有哪些最好用?推荐6款好用的项目管理工具
- 项目管理软件有哪些,盘点推荐国内外超好用的7款项目管理工具
- 2024项目管理软件排行榜(10类常用的项目管理工具全推荐)
- 项目管理软件排行榜:2024年项目经理必备5款开源项目管理软件汇总
- 2024年常用的项目管理软件有哪些?推荐这10款国内外好用的项目管理工具
- 项目管理必备:盘点2024年13款好用的项目管理软件