写点什么

精通高并发与内核 | Linux 内核与 C/Java 进程与线程深度解析

  • 2022 年 9 月 18 日
    上海
  • 本文字数:4360 字

    阅读完需:约 14 分钟

前言

📫作者简介小明java问道之路,专注于研究计算机底层/Java/Liunx 内核,就职于大型金融公司后端高级工程师,擅长交易领域的高安全/可用/并发/性能的架构设计📫 

🏆 InfoQ 签约博主、CSDN 专家博主/Java 领域优质创作者/CSDN 内容合伙人、阿里云专家/签约博主、华为云专家、51CTO 专家/TOP 红人 🏆

🔥如果此文还不错的话,还请👍关注、点赞、收藏三连支持👍一下博主~


本文导读

在网上书上,常常提及并发编程,然后吸引眼球,在前面加了一个高字,然后变成了高并发编程。

让大家觉得,并发编程为基础知识点,什么是刚并发,当在计算机中运行的任务数远大于执行指令的 CPU 个数时,方可称之为高并发。

本文将作为并发编程和 Linux 内核衔接,对线程、进程、接口设计等等基础进行详细解读,同样,请读者进行逻辑分析与知识推理,同时建立点、线、面关联。

本文也将作为 Netty、Tomcat 的线程模型设计架构 Linux 内核的基础,这些都是点,将会由使用他们的框架进行线的整合。

一、多进程的意义

前面我们笼统的说了任务这个概念,现在我们将其具象化为进程。我们知道一个程序是存放在硬盘中的一堆 01 二进制数据,而这些数据中由两部分组成:数据、指令,数据用于作为程序在运行时操作的目标,指令用于指示 CPU 完成数据的输入、运算、输出。

那么什么是进程?

答案就是已经加载放入了内存中的程序,这时 CPU 可以对其进行调度执行,也即运行中的程序为进程。那么我们是不是得有个东西来代表进程,同时保存进程的数据:进程的内存分布、进程的状态、进程的 ID 等等,那么我们将这些数据专门放到一片连续内存(例如 C 语言的结构体),这个结构体便称之为 PCB(进程控制块),而操作系统将会根据这些控制块来选择进程执行。

这时不难看出,进程的意义就是:封装执行中的程序的信息、提供 PCB 交由操作系统来调度执行。

所以,就会有这样一个结论 :进程是操作系统分配资源的基本单位。

二、多线程的意义

我们有了进程之后,可以表示一个运行中的程序,保留了该程序的内存信息、ID 信息、 CPU 状态信

息、打开文件信息、信号处理器信息(当发送信号时的处理函数, eg : kill -9 便是向线程发送了一

个信号,那么就可以找到处理该信号的处理函数来操作进程)。那么人们开始发现,如果-个 A 任

务,它需要完成多个子任务: A1、A2、A3。那么如果按照原先的方式,我们应该创建三个进程,

来同时完成,这时会加快任务的处理速度,但是,随之而来的现象便是:性能较差,不知道读者能

否发现, A1、A2、A3 这三个任务和 A 任务的进程内存数据、处理函数、打开文件、信号处理函数

均是一模一样的,不同在于执行的指令区域不太- -样,也即在 A 任务的指令中分离: A1、A2、 A3

三个区域的代码,分别让三个进程执行不同的代码区域即可( eg : Runnable 的三个实例,

Function 的三个函数) ,而我们知道每个进程都有着自己独立的数据,这时如果从 A 任务创建了三

个子进程,这就以为 A 的所有数据均需要复制出三份,这不仅消耗了时间,同时也浪费了内存。于

是乎,人们搞出了一个概念:线程,我们也称之为轻量级进程(注意这个概念,它也是进程,只不

过很轻量,轻量在哪里?往下继续看)。

三、如何实现线程

我们知道, 一个技术出来就是为了解决某个问题的,我们上面的问题在于:多个进程的内存数据、处理函数、打开文件、信号处理函数、甚至二进制执行代码都是相同的,不能共享。那么我们直接让他们共享不就行了?确实就是这样的,我们创建了 A1. A2、A3 ,可以指定让他们共享:内存数据、处理函数、打开文件、信号处理函数、二进制执行代码,然后规定他们执行二进制代码的某些区域的代码即可。这就是线程实现方式,我们来看个创建线程时,进入系统调用的复制标志位(clone flag 克隆标志位)。以下代码摘自 glibc 的源码,也即封装了系统调用的 sdk ,由于我们还没有分析到这里,读者只需要知道我们可以通过包装了系统调用的该方法来快速创建线程,而不需要理会这些内核的参数和调用过程。

static int create_ _thread (struct pthread *pd,const struct pthread_ _attr *attr,    bool stopped_ start ,STACK_ _VARIABLES_ PARMS,bool *thread_ ran) {     // 根据来自Linux内核的描述,可以看到其实内核创建线程就是传入这些标志位来创建了进程,    // 而正是由于这些标志位导致进程共享空间和属性,所以它们称之为线程    const int clone_ flags= (CLONE_ _VM| CLONE_ FS | CLONE_ FILES | CLONE_ SYSVSEM    | CLONE_ _SIGHAND | CLONE_ _THREAD    | CLONE_ SETTLS | CLONE_ PARENT_ SETTID    | CLONE_ _CHILD_ _CL EARTID    |0);
... // 调用ARCH_ CLONE开始创建线程 if(__ glibc_ _unlikely (ARCH_ _CLONE (&start_ _thread ,STACK_ _VARIABLES_ ARGS, clone_ flags, pd ,&pd->tid ,tp ,&pd->tid) ==-1)) return errno;}
复制代码

很明显了,为何叫轻量级进程(线程) , 不就是多个进程共享了资源么?这时就更容易解释了:由

于共享资源导致了多个 CPU 执行指令时可以访问相同的数据,这就需要保证互斥操作了。

四、编程语言中的线程与进程

在了解了并发编程、并发、并行、线程、进程的概念后,这一小节我们以 C 语言和 Java 语言来举例,看看创建的进程和线程和描述的是否相同,这里读者需要把上面描述的结论进行详细掌握:进程与进程之间数据完全独立(当然这里排除你编程让进程共享内存)、线程与线程之间共享数据(轻量级进程)。

1、C 语言

以下代码给出了两个例子:实例一用于创建线程、实例二用于创建进程。我们在前面看到了当调用 pthread__create 函数创建线程时,将会传入 clone flags 表明创建出来的进程需要共享哪些内容,这时这个进程为轻量级进程(线程),而对于进程创建而言调用的函数为 fork ,该函数将直接调用内核的系统调用,不会传递 clone flags ,这就意味着父子进程将完全隔离,父进程的所有数据都需要复制给子进程。不难看出,这时创建线程的性能高于创建进程(而创建线程也等同于创建进程,只不过这些进程共享数据内容)。

正因为线程需要共享数据,这时我们不得不在 func 函数中增加互斥锁来保证线程安全,这合情合理,毕竟共享了数据。而对于进程而言,创建出来相当于与父子进程毫无关系,所以我们需要使用 if else 分支来分割父子进程的执行代码,但是并不需要使用互斥锁,因为他们数据、代码均是隔离的。

// 实例一(创建线程)#include <stdio.h>#include <pthread.h>#include <stdlib.h>pthread_mutex_t mutex; // 声明互斥体int count=0;              // 声明公用变量void func() {             // 声明线程调用函数	pthread_mutex_lock(&mutex);    // 获取互斥锁    count++;                         // 变量自增    pthread_mutex_unlock(&mutex); // 释放互斥锁}
int main() { // 主函数 // 声明的pthread_t代表线程的stub ,通过这个变量来操作线程 pthread_t thread1,thread2; pthread_mutex_init(&mutex ,NULL); //初始化互斥体 // 创建两个线程同时调用func if(pthread_create(&thread1 , NULL , (void*)func, NULL)== -1) { printf("create Thread1 error !\n"); exit(1); } if(pthread_create(&thread2 , NULL , (void *)func , NULL)== -1) { printf("create Thread2 error!\n"); exit(1); } // 主函数等待两个线程完成 pthread_join(thread1 , NULL); pthread_join(thread2 , NULL); pthread_mutex_destroy(&mutex), // 释放互斥体 return 0;}
// 实例二(创建进程)#include <sys/types.h>#include <stdio.h>#include <stdlib.h>int main() { pid_t pid; // 保存子进程id if((pid=fork()) == 0) { // 返回的pid为0 ,表示当前返回的子进程(这里注意下.上面提到的要点:由于 // 进程之间数据全部隔离,但是由于子进程是由当前进程复制的,所以子进程拥有和父进程一模一样的数据, // 但是子进程返回pid为0 ,而父进程返回的pid为子进程id。所以,这里我们用if else判断句来分离父子进程的执行代码) print("child pid is %d\n" ,getpid()); // 子进程的代码(这里读者可以考虑下为何子进程的pid为0,而父进程返回后pid为子进程的ID ? ) } else if(pid>0) { printf("father pid is %d\n",getpid()); //父进程的代码 } else { //如果返回的pid小于0 ,那么表明创建失败 perror("fork error"); } printf("%s","out"); //这段代码父子进程共享数据 return 0;}
复制代码

2、Java 语言

Java 采用多线程模型,本身无法直接创建进程,而以下代码给出了两个实例:实例一用于创建线程,实例二用于创建进程。我们知道, Java 语言通过 Thread 类来表示一个线程对象 ,同时传入了一个 Runnable 对象作为线程执行体。进程的创建则是直接通过 Runtime 类直接执行 cmd 命令,这时将会在 jvm 层面通过执行 fork 函数来创建进程。

// 实例一(创建线程)new Thread()-> System.out.printIn("child thread")).start(); 
// 实例二(创建进程)Runtime.getRuntime().exec"Is");
// Linux Runtime getRuntime().exec源码static pid_t startChild(JNIEnv *env, jobject process, ChildStuff *c, const char *helperpath) { switch (C->mode) { case MODE_ VFORK: return vforkChild(c); case MODE_ FORK: return forkChild(c); #if defined(_solaris_) || defined( ALLBSD__SOURCE) case MODE_ POSIX_ SPAWN: return spawnChild(env, process, C, helperpath); #endif default: return -1; }}
// 上述源码扩展: forkChild(c)static pid_t forkChild(ChildStuff *c) { pid_t resultPid; resultPid = fork(); if (resultPid== 0) { childProcess(c); // 子进程代码 } assert(resultPid != 0); // childProcess never returns return resultPid; }
复制代码

总结

1、线程在操作系统看来也是进程,只不过是共享了数据的进程

2、线程通过 pthread_create 函数来创建 clone flags 创建线程

3、进程通过 fork 函数来创建

4、线程共享数据,所以会导致数据安全性,必要时需要互斥

5、进程由于数据代码独立,所以天生进程安全

6、进程创建成本较高(需要复制父进程的内容)

7、线程创建成本较低(只需要在父线程的内存区域内分配栈内存,其他的共享)

8、不管在 C 语言还是 Java ,线程和进程都需要进行代码指令的划分: c 语言通过传递函数指针和 if else 分支, java 在 JVM 层面也是如此

9、进程是操作系统分配资源的基本单位,同时也是 CPU 调度执行的基本单位(线程是调度单位说法不准确,因为线程只是共享了内存数据的进程而已)

发布于: 刚刚阅读数: 3
用户头像

InfoQ签约作者/技术专家/博客专家 2020.03.20 加入

🏆InfoQ签约作者、CSDN专家博主/Java领域优质创作者、阿里云专家/签约博主、华为云专家、51CTO专家/TOP红人 📫就职某大型金融互联网公司高级工程师 👍专注于研究Liunx内核、Java、源码、架构、设计模式、算法

评论

发布
暂无评论
精通高并发与内核 | Linux内核与C/Java进程与线程深度解析_并发编程_小明Java问道之路_InfoQ写作社区