# 并发编程前序一
在网上、书上,常常提及并发编程,然后吸引眼球,在前面加了一个高字,然后变成了高并发编程。笔者觉得,并发编程为基础知识点,当在计算机中运行的任务数远大于执行指令的CPU个数时,方可称之为高并发。这时读者是否应该抓住GC Root:并发编程?本文将作为并发编程的前序系列,对线程、进程、接口设计等等基础进行详细解答,同样,请读者进行逻辑分析与知识推理,同时建立点、线、面关联,务必使用混沌学习法的融合记忆与推理来学习。通过本文,建立底层计算机思维的GC Root。
本文也将作为在后面编写Netty、Tomcat的线程模型设计架构的基础,所以务必掌握其中的精髓,这些都是点,将会由使用他们的框架进行线的整合。
并发与并行
同样,我们先进行知识推理:
- 计算机三大基础组件:CPU、内存、硬盘
- CPU用于控制其他组件,同时执行指令
- 内存用于存放运行的任务的 数据 + 指令(告诉CPU要干啥)
- 硬盘用于持久化存放任务的 数据 + 指令(怎么与内存的功能一样?是的,必须一样)
- 硬盘与内存的作用相同,理论上我们只需要 CPU + 内存/硬盘 即可,那么为啥偏偏需要这两者?是由于硬盘用于持久化,硬盘的数据并不会因为掉电而丢失,这就好理解了,所以硬盘必须。那么继续来看内存的作用,硬盘由于需要持久化,所以设计和实现必然速度远远慢于CPU,那么为了弥补这一缺陷,引入了速度远快于硬盘的内存,这时又由于内存因为速度设计,所以不得不放弃持久化的功能,所以两者互相弥补,增加运行速度。读者有没有发现内存是硬盘中的数据(程序的指令和数据)缓存。
- 这时,我们可以很容易的推理:在系统中集成多个CPU,大容量内存,同时可以将这些任务从硬盘中放到内存,然后多个CPU分别执行它们即可,这时将会极大增加计算机性能
那么,此时我们可以根据以上结论对并发与并行进行定义:
- 当内存中的任务数正好等于CPU的核心数,那么理想情况下所有CPU都各自获取到了要执行的任务,那么,在同一时间将会有CPU核心数个数的任务在执行,这称之为 并行
- 当内存中的任务数正好大于CPU的核心数,那么理想情况下所有CPU都各自获取到了要执行的任务,这时将会有多余的任务得不到CPU执行,而CPU将会对这些多余的任务和当前执行的任务进行切换(调度)来分时(eg:1S = 1000ms, 500 ms 执行 A, 500 ms 执行 B,这时将一段时间切割给不同任务执行,这称之为分时执行)执行它们,这称之为 并发
小结:
- 内存是硬盘中的数据(程序的指令和数据)缓存,为了加速而设计
- 并行:同一时间 同时 执行多个任务
- 并发:分时执行多个任务
- 当分时足够小时,我们也可以称之为伪并行(eg:1s = 1000ms,切割为1ns一个任务,这时我们也可称之为并行,取决于定义的分时的间隔大小)
多进程的意义
前面我们笼统的说了任务这个概念,现在我们将其具象化为 进程。我们知道一个程序是存放在硬盘中的一堆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_CLEARTID
| 0);
...
// 调用ARCH_CLONE开始创建线程
if (__glibc_unlikely (ARCH_CLONE (&start_thread, STACK_VARIABLES_ARGS,
clone_flags, pd, &pd->tid, tp, &pd->tid)
== -1))
return errno;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
我们从上面的代码看到,当我们使用ARCH_CLONE告诉操作系统创建线程时,放入了这一系列的clone_flags。来看看这些flag有何含义:
#define CSIGNAL 0x000000ff // 退出时发送信号
#define CLONE_VM 0x00000100 // 共享虚拟内存
#define CLONE_FS 0x00000200 // 共享文件系统
#define CLONE_FILES 0x00000400 // 共享打开的文件
#define CLONE_SIGHAND 0x00000800 // 共享信号和信号处理函数
#define CLONE_IDLETASK 0x00001000 // 新的进程ID是否为0,内核专用
#define CLONE_PTRACE 0x00002000 // 父进程被trace子进程也被trace
#define CLONE_VFORK 0x00004000 // 子进程调用mm_release时唤醒父进程
#define CLONE_PARENT 0x00008000 // 子进程和创建子进程的进程形成兄弟关系,而不是父子关系
#define CLONE_THREAD 0x00010000 // 设置同一个进程组
#define CLONE_NEWNS 0x00020000 //子进程是否拥有一个新的namespace
#define CLONE_SYSVSEM 0x00040000 // 共享system v
#define CLONE_SETTLS 0x00080000 // 是否为子进程创建TLS
#define CLONE_PARENT_SETTID 0x00100000 // 是否把子进程的PID复制到参数parent_tidptr指向的用户态变量
#define CLONE_CHILD_CLEARTID 0x00200000 // 是否清除子进程在内存空间的clear_child_tid位置的ID
#define CLONE_DETACHED 0x00400000 // 未使用
#define CLONE_UNTRACED 0x00800000 // 不能强制使用CLONE_PTRACE来跟踪子进程
#define CLONE_CHILD_SETTID 0x01000000 // 保存子线程在其内存child_tidptr位置的ID
#define CLONE_STOPPED 0x02000000 // 子进程会初始为stop状态,必须通过SIGCONT信号来resume
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
很明显了,为何叫轻量级进程(线程),不就是多个进程共享了资源么?这时就更容易解释了:由于共享资源导致了多个CPU执行指令时可以访问相同的数据,这就需要保证互斥操作了。
编程语言中的线程与进程
在了解了并发编程、并发、并行、线程、进程的概念后,这一小节我们以C语言和Java语言来举例,看看创建的进程和线程和描述的是否相同,这里读者需要把上面描述的结论进行详细掌握:进程与进程之间数据完全独立(当然这里排除你编程让进程共享内存)、线程与线程之间共享数据(轻量级进程)。
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;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
// 实例二(创建进程)
#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判断句来分离父子进程的执行代码)
{
printf("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
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
Java语言
Java采用多线程模型,本身无法直接创建进程,而以下代码给出了两个实例:实例一用于创建线程,实例二用于创建进程。我们知道,Java语言通过Thread类来表示一个线程对象,同时传入了一个Runnable对象作为线程执行体。进程的创建则是直接通过Runtime类直接执行cmd命令,这时将会在jvm层面通过执行fork函数来创建线程。
// 实例一(创建线程)
new Thread(()-> System.out.println("child thread")).start();
// 实例二(创建进程)
Runtime.getRuntime().exec("ls");
// 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;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
小结
这时我们得出以下结论:
- 线程在操作系统看来也是进程,只不过是共享了数据的进程
- 线程通过pthread_create函数来创建clone flags创建线程
- 进程通过fork函数来创建
- 线程共享数据,所以会导致数据安全,必要时需要互斥
- 进程由于数据代码独立,所以天生进程安全
- 进程创建成本较高(需要复制父进程的内容)
- 线程创建成本较低(只需要在父线程的内存区域内分配栈内存,其他的共享)
- 不管在C语言还是Java,线程和进程都需要进行代码指令的划分:c语言通过传递函数指针和if else分支,java在JVM层面也是如此
- 进程是操作系统分配资源的基本单位,同时也是CPU调度执行的基本单位(线程是调度单位说法不准确,因为线程只是共享了内存数据的进程而已)