从零实现一个简单的线程库

1,141 阅读14分钟

1.文章简介

本文描述了优先调度和线程背后的基本思想。然后,为Linux系统实现一个用户级线程库。

对于像线程这样在计算机编程中必不可少的东西来说,它们背后的思想实际上非常简单。计算机的资源不是仅由一个执行线程消耗的。它们在执行不同工作的多个线程之间被大量消耗了。我们经常会听到多线程这个概念,实际上,古老的CPU一次只能运行一个线程。最近几年,多核系统和超线程出现了。这些技术允许我们同时运行多个线程。假如有4个线程在同时工作这就好像有两台计算机和两个cpu在各自进行计算。但是如果您想要编写一个使用100个线程的程序呢?如果你学习了操作系统那么你会直到有一对多的线程模型,那么它是怎么实现的这篇文件就是要实现这些。

首先明确,所有线程都由一个名为调度器的程序管理。这个调度器跟踪所有线程,然后为每个线程分配一个时间片。这个时间片是线程用于执行的指定时间量。时间片过期后,调度器再次启动。它停止执行正在运行的线程,并为新线程分配一个新的时间片。由于这些时间片非常短,所以看起来线程是并行运行的。实际发生的是不同的。正在运行的线程不断地切换到其他线程。此方法被引用为抢占线程。这在操作系统中叫做并发。

类似的方法是协程。在这里,线程必须自己调用一个函数,该函数为另一个线程生成当前线程。这种实现方式没有定时的计时器。虽然这更容易实现,但它有两个大缺点。首先,它需要大量的额外工作,程序员编写一个程序利用协程时。必须要考虑什么时候让步(让出cpu)。如果你经常让步,你的程序就会性能会很差。如果程序很少让步,其他线程将不做任何工作。如果程序不让步,其他线程将不做任何工作容易形成死锁。

2.内核级线程 和 用户级线程的比较

当你在你的C程序中调用pthread_create()函数时时,你得到的很可能是一个内核线程。内核线程是由操作系统内核管理的线程,该线程的调度器位于内核中。用户线程的情况有所不同。调度器驻留在用户空间中。这有一些有趣的暗示。通常,在用户模式下运行的程序比在内核模式下运行的程序拥有更少的特权。因此,用户级线程可能无法完全使用系统硬件。但与此同时用户级线程不用频繁的切换为内核态所以理论上效率更高。

3.什么能构造线程呢(使用api介绍)

当一个线程在运行时,它是根据一系列机器指令工作的。为了记住当前正在执行的指令,存在指令指针。指令指针指向内存中包含当前指令的地址。这个指针存储在CPU上的一个特殊寄存器中。它们存储有关程序堆栈的信息,或者仅用于计算。在实现用户级线程时,我们需要备份所有这些信息(熟悉操作系统的应该知道运行中的进程是可以被剥夺cpu的此时就需要保存这些信息再切换上下文线程同理)。需要在线程执行时保存它,然后在它被打断后继续执行时恢复它。Linux系统提供了四个与ucontext_t类型相关的函数。这样的ucontext_t结构包含线程的上下文。这四个功能是:

- getcontext(ucontext_t *ucp); 备份当前正在运行的上下文。

- setcontext(const ucontext_t *ucp);  将当前执行的上下文更改为备份上下文。

- void makecontext(ucontext_t *ucp, void (*func)(), int argc, ...); 创建新上下文。它修改了一个旧的上下文,因此它在给定的函数处开始执行。

- int swapcontext(ucontext_t *oucp, const ucontext_t *ucp); 把旧的环境换成新的环境。同时,它也支持旧的。

为了更好地理解这些函数,最好阅读它们的手册页。man getcontext这些函数不提倡使用,涉及到编写真正的程序时,请使用POSIX线程。

4.实现

接着将编写一个用户级线程库,其接口仅包含三个函数:threads_create创建一个新线程,threads_exit退出当前线程的执行,threads_join连接两个线程,即调用线程等待另一个线程调用threads_exit。实现分为三个模块。

4.1 tcb线程控制块实现

为了跟踪所有线程,需要为每个线程创建一个线程控制块。线程控制块包含将线程恢复到执行所需的所有内容(上下文)。它还存储了一些额外信息。

typedef struct {
    int id;							//线程标识符
    ucontext_t context;				//上下文信息
    bool has_dynamic_stack;			//同context
    void *(*start_routine) (void *);//		
    void *argument;
    void *return_value;
} TCB;

id是threads_create稍后返回的线程的标识符。context和has_dynamic_stack告诉我们一些关于后面解释的线程上下文的信息。start_routine是指向一个函数的指针,该函数有一个void *类型的参数和相同的返回类型,参数是start_routine的参数。return_value将用于存储start_routine的结果。需要存储它,以便以后可以通过threads_join获得它。

必须确保id保持惟一。因此,为了创建这些结构,将使用tcb_new函数,而不是简单地分配内存。

TCB *tcb_new(void)
{
    static int next_id;

    TCB *new;

    if ((new = calloc(1, sizeof(TCB))) == NULL) {//分配内存
        return NULL;
    }

    new->id = next_id++;//计数器++
    return new;
}

同样也需要一个销毁函数tcb_destroy

void tcb_destroy(TCB *block)
{
    if (block->has_dynamic_stack) {
        free(block->context.uc_stack.ss_sp);
    }

    free(block);
}

这里要注意,在创建新线程时,它需要自己的内存空间(栈)。它是使用malloc()动态分配的。在销毁线程控制块(从而销毁线程)时也需要释放它。因此是free(block->context.uc_stack.ss_sp)。

4.2队列模块

为了跟踪这些TCB结构,需要队列这种数据结构。由于这只是一个简单的实现,简单的队列就足够了。定义queue.h和queue.c

/* queue.h */
//通过队列来管理 TCB线程控制块
typedef struct queue QUEUE;


/* 
在堆上创建一个新的初始化队列。返回新的块或错误返回为空 
*/
QUEUE *queue_new(void);


/*  销毁队列,释放与之相关的所有内存。它还使
  队列中所有元素的内存被释放。 */
void queue_destroy(QUEUE *queue);


/* 返回队列中的项数。即统计队列大小 */
size_t queue_size(const QUEUE *queue);


/* 将elem添加到队列的末尾。成功返回0,失败返回非0*/
int queue_enqueue(QUEUE *queue, TCB *elem);


/* 从队列中删除第一项并返回它。调用者将
  必须释放已出队的元素。如果队列为空,则返回NULL。
  这个即为出队操作 */
TCB *queue_dequeue(QUEUE *queue);


/* 通过id删除元素。返回删除
  元素,如果不存在此类元素则为空。 */
TCB *queue_remove_id(QUEUE *queue, int id);

4.3线程模块

在threads.c模块中,创建两个全局队列:

/*
ready包含所有准备执行的线程。
completed包含调用threads_exit但尚未连接的线程。
*/
static QUEUE *ready, *completed;

/*
为了跟踪当前运行的线程,我们还创建了一个线程控制块的全局引用。
*/
static TCB *running;

这里就能够获得两个信息。

  1. 正在运行的线程(running指针跟踪的)
  2. 等待cpu空闲属于就绪态的线程(ready指针跟踪的)

至此,可以找到正在运行(running指针跟踪的)的线程。可以找到当前正在等待CPU空闲准备抢占的线程。而又因为这三个全局变量是未初始化的。就可以在threads_create函数中更改它以此实现线程状态的变更,让线程有具备动态性。

4.4定时器模块

将使用Linuz信号实现高优先级的线程。如果您尝试过Linux信号,你可能使用过alert()函数。在调用它之后,设置一个中断,然后在n秒之后,SIGALRM信号被触发。这几乎就是这个项目想要的,但还不够。alert()使用实际时间,它就像一个真正的闹钟。但是请记住,在计算机上还有许多其他进程由内核及其调度器管理。在用户线程还没有完成任何工作的情况下,等待n秒并切换当前正在运行的用户线程是没有意义的。相反,我将在getitimer()这个函数的帮助下实现一个智(弱)能计时器。它只在线程消耗一定时间后才会启动。

static bool init_profiling_timer(void)
{
    //加载信号管理器

    sigset_t all;
    sigfillset(&all);

    const struct sigaction alarm = {
        .sa_sigaction = handle_sigprof,
        .sa_mask = all,
        .sa_flags = SA_SIGINFO | SA_RESTART
    };

    struct sigaction old;

    if (sigaction(SIGPROF, &alarm, &old) == -1) {
        perror("sigaction");
        abort();
    }

    const struct itimerval timer = {
        { 0, 10000 },
        { 0, 1 }  // 启动计时器
    };

    // 让计时器运行

    if (setitimer(ITIMER_PROF, &timer, NULL) == - 1) {
        if (sigaction(SIGPROF, &old, NULL) == -1) {
            perror("sigaction");
            abort();
        }

        return false;
    }

    return true;
}

在调用该函数之后,每10000µs执行SIGPROF信号发出。然后在handle_sigprof信号处理程序中处理该信号。

static void handle_sigprof(int signum, siginfo_t *nfo, void *context)
{
    int old_errno = errno;

    // 备份当前上下文

    ucontext_t *stored = &running->context;
    ucontext_t *updated = (ucontext_t *) context;

    stored->uc_flags = updated->uc_flags;
    stored->uc_link = updated->uc_link;
    stored->uc_mcontext = updated->uc_mcontext;
    stored->uc_sigmask = updated->uc_sigmask;

    // 让running中的队首进入ready就绪队列队尾剥夺它的cpu使用权

    if (queue_enqueue(ready, running) != 0) {
        abort();
    }
    //ready队列对手出队获得cpu
    if ((running = queue_dequeue(ready)) == NULL) {
        abort();
    }

    // 手动退出信号处理器

    errno = old_errno;
    if (setcontext(&running->context) == -1) {
        abort();
    }
}

这里需要理解的是,信号处理程序通常在它们自己的上下文中运行。它不同于程序得到信号之前运行的线程的上下文。如果调用getcontext(),可能会得到错误的上下文(因为操作系统的异步性)。为了避免这种情况,使用信号处理程序的最后一个参数context。它是一个指向ucontext_t结构的指针,该结构表示在运行信号处理程序之前的上下文。查看man getcontext这个函数可以得到更详细的信息。

可以使用这个参数来理解线程背后的思想。只是不要在任何生产代码中使用它!

那么我的线程调度逻辑如下: 首先,通过正在运行的线程的ucontext_t结构来保存当前(旧)的上下文。然后,若占用时间过长将正在运行的线程控制块放在正在运行(running)的队列的末尾。为了让下一个线程排队,从就绪队列(ready)中取出一个线程控制块。这个退出就绪队列的线程成为新的运行队列中的线程。更新运行队列(running),然后调用setcontext(&running->context)。这就是所有的调度逻辑。每个线程都有相同的时间片(它们的优先级都是相同的),并且它们都按顺序工作。这种最基本的调度通常称为循环调度。

4.5线程同步的实现

执行调用init_profiling_timer之后,每10000µs当前运行的线程被中断。这里有一个新问题,如果同时修改两个队列,队列可能会损坏(保存的信息重复)。因此,将在某些函数中阻塞SIGPROF信号。Linux提供了sigprocmask函数来实现这一点。使用它有点麻烦,所以让写两个辅助函数(你也可以称之为包裹函数)。

看名字就知道了作用是上锁和解锁。线程同步Linux中有读写锁,锁,信号量,条件变量等同步机制这里也是实现了最简单的锁机制。

static void block_sigprof(void)
{
    sigset_t sigprof;
    sigemptyset(&sigprof);
    sigaddset(&sigprof, SIGPROF);

    if (sigprocmask(SIG_BLOCK, &sigprof, NULL) == -1) {
        perror("sigprocmask");
        abort();
    }
}


static void unblock_sigprof(void)
{
    sigset_t sigprof;
    sigemptyset(&sigprof);
    sigaddset(&sigprof, SIGPROF);

    if (sigprocmask(SIG_UNBLOCK, &sigprof, NULL) == -1) {
        perror("sigprocmask");
        abort();
    }
}

4.6几个线程api的实现

4.6.1 threads_create的实现

当第一次调用threads_create时,线程模块仍然没有初始化。有三件事需要初始化。首先是准备好ready和completed队列。辅助函数会处理这个问题。

//调用queue_new函数就可以了
static bool init_queues(void)
{
    if ((ready = queue_new()) == NULL) {
        return false;
    }

    if ((completed = queue_new()) == NULL) {
        queue_destroy(ready);
        return false;
    }

    return true;
}

其次,需要对原文进行中断通知。当程序第一次开始执行时,可以认为它是在一个线程中运行的。但是我的库函数对此一无所知。我们写一个函数init_first_context来改变它。在其中这个函数中,当前运行的上下文在线程控制块中进行备份。由于第一个线程实际上是当前正在运行的线程,所以将其设置为全局变量。

static bool init_first_context(void)
{
    TCB *block;

    if ((block = tcb_new()) == NULL) {
        return false;
    }

    if (getcontext(&block->context) == -1) {
        tcb_destroy(block);
        return false;
    }

    running = block;
    return true;
}

要做的最后一件事是初始化定时器。为此,将使用已经讨论过的函数init_profiling_timer。现在可以实现threads_create函数了。函数的前三分之一负责使用前面的函数进行初始化。

现在需要实际创建新线程,即线程控制块。创建之后,使用getcontext()来捕获当前上下文,分配用作线程的存储空间,然后使用makecontext()来更改捕获的上下文以适应新线程的参数。

int threads_create(void *(*start_routine) (void *), void *arg)
{
    block_sigprof();//要操作队列别忘了上锁

    // 初始化

    static bool initialized;

    if (! initialized) {
        if (! init_queues()) {
            abort();
        }

        if (! init_first_context()) {
            abort();
        }

        if (! init_profiling_timer()) {
            abort();
        }

        initialized = true;
    }
    //下面的代码为实际创建线程
    // 为新创建的线程创建一个线程控制块。

    TCB *new;

    if ((new = tcb_new()) == NULL) {
        return -1;
    }

    if (getcontext(&new->context) == -1) {
        tcb_destroy(new);
        return -1;
    }

    if (! malloc_stack(new)) {
        tcb_destroy(new);
        return -1;
    }

    makecontext(&new->context, handle_thread_start, 1, new->id);

    new->start_routine = start_routine;
    new->argument = arg;

    // Enqueue the newly created stack

    if (queue_enqueue(ready, new) != 0) {
        tcb_destroy(new);
        return -1;
    }

    unblock_sigprof();
    return new->id;
}

使用了两个到目前为止还没有提到的函数:malloc_stack和handle_thread_start作为makecontext()调用的参数。

第一个函数malloc_stack动态分配内存然后修改存储在线程控制块中的上下文以包含该栈区空间。

static bool malloc_stack(TCB *thread)
{
    // 得到分配的栈的大小

    struct rlimit limit;

    if (getrlimit(RLIMIT_STACK, &limit) == -1) {
        return false;
    }

    // 分配存储空间

    void *stack;

    if ((stack = malloc(limit.rlim_cur)) == NULL) {
        return false;
    }

    // 更新tcb线程控制块

    thread->context.uc_stack.ss_flags = 0;
    thread->context.uc_stack.ss_size = limit.rlim_cur;
    thread->context.uc_stack.ss_sp = stack;
    thread->has_dynamic_stack = true;

    return true;
}

ss_size是栈的大小,ss_sp是栈的开始,ss_flags设置为0表示一个新的栈。有关详细信息,请参阅getcontext()的手册。man getcontext

第二个更有趣的函数是与makecontext()一起使用的handle_thread_start。makecontext()采用通常通过getcontext()获得的ucontext_t结构并修改它。一旦修改的上下文被激活,执行就从函数func开始,参数int的数量是可变的。这些参数被传递给makecontext()。同样,在手册中描述了详细信息。

现在应该注意到makecontext()的接口与posix的接口不匹配。makecontext()从一个带有int参数的函数开始,但是需要void*参数。出于这个原因,需要一个包裹函数handle_thread_start改变其接口。

static void handle_thread_start(void)
{
    block_sigprof();
    TCB *this = running;//操作队列别忘了上锁哦
    unblock_sigprof();

    void *result = this->start_routine(this->argument);
    threads_exit(result);
}

这个包裹函数实际上做的更多一些,它还调用threads_exit。这样,保证用我们的库创建的每个线程都调用threads_exit。即使线程只执行返回操作。

4.6.2 threads_exit的实现

threads_exit相当简单。首先,它将当前正在运行的线程控制块放入已完成的队列中。然后将处理器转到下一个线程。

void threads_exit(void *result)
{
    if (running == NULL) {
        exit(EXIT_SUCCESS);
    }

    block_sigprof();

    running->return_value = result;

    if (queue_enqueue(completed, running) != 0) {
        abort();
    }

    if ((running = queue_dequeue(ready)) == NULL) {
        exit(EXIT_SUCCESS);
    }

    setcontext(&running->context);  // also unblocks SIGPROF
}

4.6.3 threads_join的实现

int threads_join(int id, void **result)
{
    if (id < 0) {
        errno = EINVAL;
        return -1;
    }

    block_sigprof();
    TCB *block = queue_remove_id(completed, id);
    unblock_sigprof();

    if (block == NULL) {
        return 0;
    } else {
        *result = block->return_value;
        tcb_destroy(block);
        return id;
    }
}

hreads_join所做的就是遍历已完成的队列。如果找到与id匹配的元素,则永久销毁该线程控制块。返回值被写入result中。

5测试代码

//main.c
#include "threads.h"

#include <stdio.h>
#include <stdlib.h>


static void *thread1(void *arg)
{
    if ((unsigned long) arg % 2 == 0) {
  return arg;
    } else {
  for (;;) {
  }
    }
}


int main(void)
{
    puts("Hello, this is main().");


    int threads[8];

    for (unsigned long i = 0; i < 8; ++i) {
  void *arg = (void *) i;

  if ((threads[i] = threads_create(thread1, arg)) == -1) {
      perror("threads_create");
      exit(EXIT_FAILURE);
  }
    }

    for (int i = 0; i < 8; ++i) {
  int id = threads[i];

  while (1) {
      void *res;

      if (threads_join(id, &res) > 0) {
    printf("joined thread %d with result %p\n", id, res);
    break;
      }
  }
    }
}

这样整个线程库就完成了,代码实现在https://github.com/helintongh/SimpleThreadLibrary 。 下载代码后执行make就可以得到可执行文件了。