【问题标题】:How are user-level threads scheduled/created, and how are kernel level threads created?用户级线程是如何调度/创建的,内核级线程是如何创建的?
【发布时间】:2017-01-04 05:43:11
【问题描述】:

如果这个问题很愚蠢,我们深表歉意。我试图在网上找到很长一段时间的答案,但找不到,因此我在这里问。我正在学习线程,我一直在经历this linkthis Linux Plumbers Conference 2013 video关于内核级和用户级线程,据我了解,使用pthreads在用户空间创建线程,内核不知道这一点并将其仅视为单个进程,不知道里面有多少线程。在这种情况下,

  • 谁来决定这些用户线程在进程获得的时间片内的调度,因为内核将其视为单个进程并且不知道线程,以及调度是如何完成的?
  • 如果 pthreads 创建用户级线程,如果需要,如何从用户空间程序创建内核级或操作系统线程?
  • 根据上面的链接,它说操作系统内核提供系统调用来创建和管理线程。那么clone() 系统调用会创建内核级线程还是用户级线程?
    • 如果它创建一个内核级线程,那么一个简单的pthreads programstrace 也会在执行时显示使用clone(),但是为什么它会被视为用户级线程呢?
    • 如果它不创建内核级线程,那么如何从用户空间程序创建内核线程?
  • 根据链接,它说“每个线程都需要一个完整的线程控制块 (TCB) 来维护有关线程的信息。因此存在大量开销并增加了内核复杂性。”,因此在内核级线程中,只有堆是共享的,其余的都是线程独立的?

编辑:

我问的是用户级线程的创建,它是调度,因为here, 引用了多对一模型,其中许多用户级线程映射到一个内核级线程,线程管理在线程库的用户空间。我只看到使用 pthreads 的引用,但不确定它是创建用户级线程还是内核级线程。

【问题讨论】:

  • pthread_create 函数创建一个内核线程。事实上,在 Linux 中,线程和进程之间并没有太大区别,进程和线程基本上都是以相同的方式创建的,只是 the clone function 的标志不同。如今,很少有主要操作系统没有以一种或另一种方式使用本机线程。
  • @JoachimPileborg 谢谢。那么这是否意味着 pthread_create 创建内核线程,与用户级线程相比,这是缓慢且低效的?如果有,如何创建用户级线程?
  • 查看 Joachim 关于用户线程的评论。内核平等地调度进程和线程——它完全了解用户空间线程。这些被称为“本机”线程。然而,内核线程完全不同。它们通过kernel_thread 函数从内核中启动[并且不能从用户空间启动]。这些是在保护模式下运行的线程,内核地址空间,并且是由内核为自己的目的而创建的。调度程序以类似的方式处理 所有 线程以进行时间片 [但内核线程有一些特殊的抢占差异]。
  • @CraigEstey 感谢您的解释。但是,很抱歉我现在很困惑,因为 Joachim 提到 pthread_create 函数创建了一个内核线程,但您的评论说 kernel_thread 函数创建了内核线程。我已经阅读了多对一模型映射,其中许多用户级线程映射到一个内核级线程,因此这个问题。我也会用它来编辑问题。再次感谢您的友好回复。
  • 在 Linux 上没有用户线程,至少现在没有了。曾经有一个实现用户线程的“绿色”线程包,但是当内核添加对线程的完全支持时,它被删除了。一般来说,“内核”与“用户”仅指pthreads API 下的实现,如果平台不支持操作系统本身的线程并且特定的用户线程实现需要特定的调用或应用程序代码中的其他特殊行为正常工作。

标签: c++ c linux multithreading linux-kernel


【解决方案1】:

这是由顶级 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) 之后确实理解线程的所有内核。 没有线程主控,但是,我们有pthreadsclone 系统调用。现在,fork 实现为 cloneclonefork 类似,但需要一些参数。值得注意的是,flags 参数和 child_stack 参数。

下面有更多内容...

那么,用户级线程怎么可能有单独的堆栈呢?

处理器堆栈没有什么“神奇”之处。我将[主要] 将讨论限制在 x86 上,但这适用于任何架构,甚至那些甚至没有堆栈寄存器的架构(例如 1970 年代的 IBM 大型机,例如 IBM System 370)

在 x86 下,堆栈指针为%rsp。 x86 有pushpop 指令。我们使用这些来保存和恢复内容:push %rcx 和 [稍后] pop %rcx

但是,假设 x86 没有%rsppush/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_createclone,但我们仍然必须创建新线程的堆栈。内核为新线程创建/分配堆栈。 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?

【讨论】:

  • 一个小疑问。您提到内核不了解用户线程。那么,用户级线程怎么可能有单独的堆栈呢?
  • 非常感谢您考虑我的问题。
【解决方案2】:

LinuxThreads 遵循所谓的“一对一”模型:每个线程实际上是内核中的一个独立进程。内核调度器负责调度线程,就像它调度常规进程一样。线程是使用 Linux clone() 系统调用创建的,它是 fork() 的泛化,允许新进程共享父进程的内存空间、文件描述符和信号处理程序。

来源——Xavier Leroy(LinuxThreads 的创建者)的采访 http://pauillac.inria.fr/~xleroy/linuxthreads/faq.html#K

【讨论】:

  • 链接无法访问
【解决方案3】:

用户级线程通常是协程,以一种或另一种形式存在。在 user 模式下的执行流之间切换上下文,不涉及内核。从内核 POV 来看,都是一个线程。线程实际上做什么是在用户模式中控制的,用户模式可以暂停、切换、恢复执行的逻辑流程(即协程)。这一切都发生在为实际线程安排的量子期间。内核可以并且会毫不客气地中断实际线程(内核线程)并将处理器的控制权交给另一个线程。

用户模式协程需要协作多任务处理。用户模式线程必须定期将控制权交给其他用户模式线程(基本上执行将 context 更改为新的用户模式线程,而内核线程不会注意到任何事情)。通常发生的情况是,当代码想要释放内核会释放的控制权时,它会更好地了解代码。编码不佳的协程可能会窃取控制权并使所有其他协程饿死。

历史实现使用setcontext,但现在已弃用。 Boost.context 提供了替代品,但不是完全可移植的:

Boost.Context 是一个基础库,可在单个线程上提供一种协作式多任务处理。通过提供当前线程中当前执行状态的抽象,包括堆栈(带有局部变量)和堆栈指针、所有寄存器和 CPU 标志以及指令指针,execution_context 表示应用程序执行路径中的特定点。

毫不奇怪,Boost.coroutine 基于 Boost.context。

Windows 提供了Fibers。 .Net 运行时有 Tasks 和 async/await。

【讨论】:

  • 谢谢@Remus-rusanu。为什么用户模式协程需要协同多任务处理?它是否与旧的 linux 内核有关,它永远不允许在忙碌时抢占任务?
  • 用户模式不能有抢占式多任务,切换到另一个线程没有中断。协程必须自愿让步。
  • 在我的回答中,pre-NPTL、pre-native 实现 [in glibc] 确实 具有抢占式多任务处理。 “中断”是一个信号(例如SIGUSR1SIGUSR2SIGALRM)。代码很混乱,但甚至支持使用一些巨大的技巧来阻塞 I/O 操作。不是很好,但它奏效了。它不得不,因为当时别无选择。现在,我们可以更干净地使用本机线程和协程(它们是[谢天谢地:-)] 我提到的 LWP 的一个子集来做这两者。而且,IIRC 已被提出,但尚未被 ISO/C++ 接受?)
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2022-10-07
  • 2022-08-15
  • 2012-11-01
  • 2015-04-08
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多