这是由顶级 cmets 开头的。
您正在阅读的文档是通用的 [不是特定于 linux 的] 并且有点过时。而且,更重要的是,它使用了不同的术语。我相信,这就是混乱的根源。所以,请继续阅读...
它所谓的“用户级”线程就是我所说的[过时] LWP 线程。它所谓的“内核级”线程就是 linux 中所谓的 native 线程。在 linux 下,所谓的“内核”线程完全是另外一回事[见下文]。
使用 pthreads 在用户空间创建线程,内核不知道这一点并将其仅视为单个进程,不知道里面有多少线程。
这就是在NPTL(本机 posix 线程库)之前用户空间线程的。这也是 SunOS/Solaris 所称的LWP 轻量级进程。
有一个进程多路复用自身并创建线程。 IIRC,它被称为线程主进程[或类似的]。内核不意识到这一点。内核还没有理解或提供对线程的支持。
但是,因为这些“轻量级”线程是由基于用户空间的线程主控(又名“轻量级进程调度程序”)[只是一个特殊的用户程序/进程]中的代码切换的,因此它们切换上下文的速度非常慢。
此外,在“本机”线程出现之前,您可能有 10 个进程。每个进程获得 10% 的 CPU。如果其中一个进程是具有 10 个线程的 LWP,则这些线程必须共享这 10%,因此,每个进程只能获得 1% 的 CPU。
所有这些都被内核调度程序知道的“本机”线程所取代。这种转变是在 10 到 15 年前完成的。
现在,在上面的示例中,我们有 20 个线程/进程,每个线程/进程获得 5% 的 CPU。而且,上下文切换要快得多。
仍然可以在本地线程下拥有 LWP 系统,但现在,这是一种设计选择,而不是必需品。
此外,如果每个线程都“合作”,LWP 会很好地工作。也就是说,每个线程循环都会定期对“上下文切换”函数进行显式调用。它自愿放弃进程槽,以便另一个 LWP 可以运行。
然而,glibc 中的 pre-NPTL 实现也必须 [强制] 抢占 LWP 线程(即实现时间片)。我不记得使用的确切机制,但是,这里有一个例子。线程主控必须设置警报,进入睡眠状态,醒来然后向活动线程发送信号。信号处理程序将影响上下文切换。这很混乱、丑陋,而且有点不可靠。
Joachim 提到pthread_create 函数创建内核线程
称它是一个内核线程[技术上]是不正确的。 pthread_create 创建一个 native 线程。这是在用户空间中运行的,并且在与进程平等的基础上争夺时间片。一旦创建,线程和进程之间几乎没有区别。
主要区别在于进程有自己唯一的地址空间。但是,线程是与属于同一线程组的其他进程/线程共享其地址空间的进程。
如果它不创建内核级线程,那么如何从用户空间程序创建内核线程?
内核线程不是用户空间线程、NPTL、本机或其他。它们是由内核通过kernel_thread 函数创建的。它们作为内核的一部分运行,并且不与任何用户空间程序/进程/线程相关联。他们可以完全访问机器。设备、MMU 等。内核线程运行在最高权限级别:ring 0。它们也在内核的地址空间中运行,不是任何用户进程/线程的地址空间。
用户空间程序/进程可能不创建内核线程。请记住,它使用pthread_create 创建一个native 线程,该线程调用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 %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 或一个 native 一个,启动进程/线程必须使用 @ 为提议的线程预先分配区域987654365@。 旁注: 使用malloc 是正常的方式,但线程创建者可以只拥有一个大的全局内存池:char stack_area[MAXTASK][0x100000];,如果它希望这样做的话。
如果我们有一个不使用线程[任何类型]的普通程序,它可能希望“覆盖”它已经给定的默认堆栈。
如果该进程正在执行大量递归函数,则该进程可能决定使用malloc 和上述汇编程序技巧来创建更大的堆栈。
在这里查看我的答案:What is the difference between user defined stack and built in stack in use of memory?