从 Linux 线程创建到 docker 的 namespace

2024-09-01 13:38:39 浏览数 (4)

Linux 的进程和线程

在开始话题之前,首先我们来说,对于软件的开发来说,什么样的东西是最难的?有的人可能说是某些硬件交互,也可能是环境适配,数据的一致性,但是对于基础软件产品来说,架构设计是最顶层的,而其他不过是基于设计的首先而已。

Linux 早期是没有线程的概念,因此他只设计了进程的结构体,Linux 上是怎么设计线程的呢?就像在用户量较小年代,Linux 的 I/O 多路复用 select 也足够支持,epoll 的出现让服务器可以轻松实现百万并发。

进程创建方式

首先我们用 c 语言实现一个经典的进程创建

代码语言:c复制
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h> // 包含fork()函数
#include <sys/wait.h> // 包含wait()函数

int main() {
    pid_t pid = fork();

    if (pid < 0) { // fork失败
        fprintf(stderr, "Fork failedn");
        return 1;
    } else if (pid == 0) { // 子进程
        printf("This is the child process with PID: %dn", getpid());
        printf("Child process exiting...n");
        exit(0); // 子进程正常退出
    } else { // 父进程
        printf("This is the parent process with PID: %dn", getpid());
        printf("Waiting for child process to finish...n");
        wait(NULL); // 等待子进程结束
        printf("Child process finished.n");
    }

    return 0;
}

上述代码执行逻辑很让人费解,因为在我们的逻辑里边 if else 是非此即彼的一种,但是在这段代码中两个可以同时运行的。

代码执行代码执行

可以看到父进程和子进程运行是并行的,都会运行,两者之间关系就是在子进程 exit 之后父进程可以接收到。

这样理解可能不是很好,但是我们换一个进程创建函数使用 clone

代码语言:c复制
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int child_func(void *arg) {
    printf("Hello from child process! PID: %dn", getpid());
    return 0;
}

int main() {
    const int STACK_SIZE = 1024 * 1024;
    void *stack = malloc(STACK_SIZE);

    if (stack == NULL) {
        perror("malloc failed");
        exit(1);
    }

    pid_t pid = clone(child_func, stack   STACK_SIZE, SIGCHLD, NULL);

    if (pid == -1) {
        perror("clone failed");
        exit(1);
    }

    printf("Parent process waiting for child to finish...n");
    wait(NULL);

    free(stack);
    return 0;
}

我们主要关注 clone 函数参数, child_func 表示子进程的处理逻辑,在fork 里边就是 pid == 0 逻辑里边的代码。

第二个是栈指针,表示整个进程空间的栈指针位置,第三个是信号,SIGCHLD 表示子进程退出通知父进程,我们上边的 exit --> wait 逻辑就是这么产生的。

运行结果运行结果

虽然到这里这里引入概念讲完了,但是我们还是要写一下 Linux 线程创建方式。

代码语言:c复制
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>

void* threadFunction(void* arg) {
    int threadNum = *((int*)arg);
    printf("Hello from thread %dn", threadNum);
    return NULL;
}

int main() {
    pthread_t thread;
    int threadNum = 1;

    // 创建子线程
    if (pthread_create(&thread, NULL, threadFunction, &threadNum)) {
        fprintf(stderr, "Error creating threadn");
        return 1;
    }

    // 等待子线程完成
    if (pthread_join(thread, NULL)) {
        fprintf(stderr, "Error joining threadn");
        return 2;
    }

    printf("Thread has finished executingn");
    return 0;
}

注意pthread 不是libc标准库,因此编译时候需要引入 -pthread 编译命令。

代码语言:c复制
gcc -o pthread pthread.c -pthread

内核创建进程和线程的差别

已经有很多资料都讲了, Linux 创建进程是通过复制父进程的 task_struct 结构,然后通过写时拷贝机制进行数据分离,这里也就不再赘述。

这里主要讲述一下线程和进程创建之间差距,注意,以下内容会省去很多中间过程。限于篇幅和精力不能很详细介绍。

这里使用 Linux 6.10.7 的源码来解析

进程创建的入口是如下代码

代码语言:c复制
#ifdef __ARCH_WANT_SYS_FORK
SYSCALL_DEFINE0(fork)
{
#ifdef CONFIG_MMU
	struct kernel_clone_args args = {
		.exit_signal = SIGCHLD,
	};

	return kernel_clone(&args);
#else
	/* can not support in nommu mode */
	return -EINVAL;
#endif
}
#endif

可以看到调用了kernel_clone 函数,然后传入参数是 args ,其中exit_signal 是 SIGCHLD

而可以看一下线程创建函数,这个是封装在 glibc 库中

代码语言:c复制
static int create_thread (struct pthread *pd, const struct pthread_attr *attr,
			  bool *stopped_start, void *stackaddr,
			  size_t stacksize, bool *thread_ran)
{
   .......

  /* We rely heavily on various flags the CLONE function understands:

     CLONE_VM, CLONE_FS, CLONE_FILES
	These flags select semantics with shared address space and
	file descriptors according to what POSIX requires.

     CLONE_SIGHAND, CLONE_THREAD
	This flag selects the POSIX signal semantics and various
	other kinds of sharing (itimers, POSIX timers, etc.).

     CLONE_SETTLS
	The sixth parameter to CLONE determines the TLS area for the
	new thread.

     CLONE_PARENT_SETTID
	The kernels writes the thread ID of the newly created thread
	into the location pointed to by the fifth parameters to CLONE.

	Note that it would be semantically equivalent to use
	CLONE_CHILD_SETTID but it is be more expensive in the kernel.

     CLONE_CHILD_CLEARTID
	The kernels clears the thread ID of a thread that has called
	sys_exit() in the location pointed to by the seventh parameter
	to CLONE.

     The termination signal is chosen to be zero which means no signal
     is sent.  */
  const int clone_flags = (CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SYSVSEM
			   | CLONE_SIGHAND | CLONE_THREAD
			   | CLONE_SETTLS | CLONE_PARENT_SETTID
			   | CLONE_CHILD_CLEARTID
			   | 0);

  TLS_DEFINE_INIT_TP (tp, pd);

  struct clone_args args =
    {
      .flags = clone_flags,
      .pidfd = (uintptr_t) &pd->tid,
      .parent_tid = (uintptr_t) &pd->tid,
      .child_tid = (uintptr_t) &pd->tid,
      .stack = (uintptr_t) stackaddr,
      .stack_size = stacksize,
      .tls = (uintptr_t) tp,
    };
  int ret = __clone_internal (&args, &start_thread, pd);
 ....

  return 0;
}

这是glibc 2.39 的源码,可以看到他设置了 flag 然后调用 __clone_internal 函数,而他的底层就是我们前边的 clone 函数。

你可能会好奇,clone 不是子进程创建吗,为什么也可以创建线程,这个时候就是 clone_flags 的作用了,我们看到线程创建传入了很多 flag ,而这就是进程创建和线程创建的区别。

代码语言:c复制
clone(child_func, stack   STACK_SIZE, SIGCHLD, NULL);

我们看到了在创建子进程时候只传入了 SIGCHLD

而在 创建线程的时候传入了

代码语言:c复制
const int clone_flags = (CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SYSVSEM
			   | CLONE_SIGHAND | CLONE_THREAD
			   | CLONE_SETTLS | CLONE_PARENT_SETTID
			   | CLONE_CHILD_CLEARTID
			   | 0);

而这几个标记是否传入表示复制的 task_struct 结构体中几个共享的会不会进行复制。

这里只说结论,创建子线程 task_struct 里边 mm_struct fs_struct files_struct 会共享,而子进程会单独开辟一段内存。

所以为什么会说Linux 里边的线程是轻量级的进程,两个相差并不大,相同点多余共同点。

namespace

所以为什么从线程谈到 docker ,因为 docker 的 namespace 就是依靠这几个标记实现进程隔离,使得 pid ipc 等产生隔离。

代码语言:c复制
#define CSIGNAL		0x000000ff	/* signal mask to be sent at exit */
#define CLONE_VM	0x00000100	/* set if VM shared between processes */
#define CLONE_FS	0x00000200	/* set if fs info shared between processes */
#define CLONE_FILES	0x00000400	/* set if open files shared between processes */
#define CLONE_SIGHAND	0x00000800	/* set if signal handlers and blocked signals shared */
#define CLONE_PIDFD	0x00001000	/* set if a pidfd should be placed in parent */
#define CLONE_PTRACE	0x00002000	/* set if we want to let tracing continue on the child too */
#define CLONE_VFORK	0x00004000	/* set if the parent wants the child to wake it up on mm_release */
#define CLONE_PARENT	0x00008000	/* set if we want to have the same parent as the cloner */
#define CLONE_THREAD	0x00010000	/* Same thread group? */
#define CLONE_NEWNS	0x00020000	/* New mount namespace group */
#define CLONE_SYSVSEM	0x00040000	/* share system V SEM_UNDO semantics */
#define CLONE_SETTLS	0x00080000	/* create a new TLS for the child */
#define CLONE_PARENT_SETTID	0x00100000	/* set the TID in the parent */
#define CLONE_CHILD_CLEARTID	0x00200000	/* clear the TID in the child */
#define CLONE_DETACHED		0x00400000	/* Unused, ignored */
#define CLONE_UNTRACED		0x00800000	/* set if the tracing process can't force CLONE_PTRACE on this clone */
#define CLONE_CHILD_SETTID	0x01000000	/* set the TID in the child */
#define CLONE_NEWCGROUP		0x02000000	/* New cgroup namespace */
#define CLONE_NEWUTS		0x04000000	/* New utsname namespace */
#define CLONE_NEWIPC		0x08000000	/* New ipc namespace */
#define CLONE_NEWUSER		0x10000000	/* New user namespace */
#define CLONE_NEWPID		0x20000000	/* New pid namespace */
#define CLONE_NEWNET		0x40000000	/* New network namespace */
#define CLONE_IO		0x80000000	/* Clone io context */

上述就是子进程创建的标志。

当然从开发者角度来看就是,设计需求变更时候加个字段的事情。

1 人点赞