多线程与多进程

Posted 狗蛋儿l

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了多线程与多进程相关的知识,希望对你有一定的参考价值。

线程引入

传统的C++(C++98)中并没有引入线程这个概念。linux和unix操作系统的设计采用的是多进程,进程间的通信十分方便,同时进程之间互相有着独立的空间,不会污染其他进程的数据,天然的隔离性给程序的稳定性带来了很大的保障。而线程一直都不是linux和unix推崇的技术,甚至有传言说linus本人就非常不喜欢线程的概念。随着C++市场份额被Java、Python等语言所蚕食,为了使得C++更符合现代语言的特性,在C++11中引入了多线程与并发技术。

一.何为进程?何为线程?两者有何区别?

1.何为进程?

进程是一个应用程序被操作系统拉起来加载到内存之后从开始执行到执行结束的这样一个过程。简单来说,进程是程序(应用程序,可执行文件)的一次执行。进程通常由程序、数据和进程控制块(PCB)组成。比如双击打开一个桌面应用软件就是开启了一个进程。

传统的进程有两个基本属性:可拥有资源的独立单位;可独立调度和分配的基本单位。对于这句话我的理解是:进程可以获取操作系统分配的资源,如内存等;进程可以参与操作系统的调度,参与CPU的竞争,得到分配的时间片,获得处理机(CPU)运行。

进程在创建、撤销和切换中,系统必须为之付出较大的时空开销,因此在系统中开启的进程数不宜过多。比如你同时打开十几个应用软件试试,电脑肯定会卡死的。于是紧接着就引入了线程的概念。

2.何为线程?

线程是进程中的一个实体,是被系统独立分配和调度的基本单位。也有说,线程是CPU可执行调度的最小单位。也就是说,进程本身并不能获取CPU时间,只有它的线程才可以。

引入线程之后,将传统进程的两个基本属性分开了,线程作为调度和分配的基本单位,进程作为独立分配资源的单位。我对这句话的理解是:线程参与操作系统的调度,参与CPU的竞争,得到分配的时间片,获得处理机(CPU)运行。而进程负责获取操作系统分配的资源,如内存。

线程基本上不拥有资源,只拥有一点运行中必不可少的资源,它可与同属一个进程的其他线程共享进程所拥有的全部资源。

线程具有许多传统进程所具有的特性,故称为“轻量型进程”。同一个进程中的多个线程可以并发执行。

3.进程和线程的区别?

其实根据进程和线程的定义已经能区分开它们了。

线程分为用户级线程和内核支持线程两类,用户级线程不依赖于内核,该类线程的创建、撤销和切换都不利用系统调用来实现;内核支持线程依赖于内核,即无论是在用户进程中的线程,还是在系统中的线程,它们的创建、撤销和切换都利用系统调用来实现。

但是,与线程不同的是,无论是系统进程还是用户进程,在进行切换时,都要依赖于内核中的进程调度。因此,无论是什么进程都是与内核有关的,是在内核支持下进程切换的。尽管线程和进程表面上看起来相似,但是他们在本质上是不同的。

根据操作系统中的知识,进程至少必须有一个线程,通常将此线程称为主线程。

进程要独立的占用系统资源(如内存),而同一进程的线程之间是共享资源的。进程本身并不能获取CPU时间,只有它的线程才可以。

4.其他

进程在创建、撤销和切换过程中,系统的时空开销非常大。用户可以通过创建线程来完成任务,以减少程序并发执行时付出的时空开销。例如可以在一个进程中设置多个线程,当一个线程受阻时,第二个线程可以继续运行,当第二个线程受阻时,第三个线程可以继续运行…。这样,对于拥有资源的基本单位(进程),不用频繁的切换,进一步提高了系统中各种程序的并发程度。

在一个应用程序(进程)中同时执行多个小的部分,这就是多线程。这小小的部分虽然共享一样的数据,但是却做着不同的任务。

二.何为并发?C++中如何解决并发问题?C++中多线程的语言实现?

1.何为并发?

1.1.并发

在同一个时间里CPU同时执行两条或多条命令,这就是所谓的并发。

1.2.伪并发

伪并发是一种看似并发的假象。我们知道,每个应用程序是由若干条指令组成的。在现代计算机中,不可能一次只跑一个应用程序的命令,CPU会以极快的速度不停的切换不同应用程序的命令,而让我们看起来感觉计算机在同时执行很多个应用程序。比如,一边听歌,一边聊天,还能同时打游戏,我们误以为这是并发,其实只是一种伪并发的假象。

主要,以前的计算机都是单核CPU,就不太可能实现真正的并发,只能是不同的线程占用不同的时间片,而CPU在各个线程之间来回快速的切换。

伪并发的模型大致如下:

整个框代表一个CPU的运行,T1和T2代表两个不同的线程,在执行期间,不同的线程分别占用不同的时间片,然后由操作系统负责调度执行不同的线程。但是很明显,由于内存、寄存器等等都是有限的,所以在执行下一个线程的时候不得不把上一个线程的一些数据先保存起来,这样下一次执行该线程的时候才能继续正确的执行。

这样多线程的好处就是更大的利用CPU的空闲时间,而缺点就是要付出一些其他的代价,所以多线程是否一定要单线程快呢?答案是否定的。这个道理就像,如果有3个程序员同时编写一个项目,不可避免需要相互的交流,如果这个交流的时间远远大于编码的时间,那么抛开代码质量来说,可能还不如一个程序猿来的快。

理想的并发模型如下:

可以看出,这是真正的并发,真正实现了时间效率上的提高。因为每一个框代表一个CPU的运行,所以真正实现并发的物理基础的多核CPU。

1.3.并发的物理基础

慢慢的,发展出了多核CPU,这样就为实现真并发提供了物理基础。但这仅仅是硬件层面提供了并发的机会,还需要得到语言的支持。像C++11之前缺乏对于多线程的支持,所写的并发程序也仅仅是伪并发。

也就是说,并发的实现必须首先得到硬件层面的支持,不过现在的计算机已经是多核CPU了,我们对于并发的研究更多的是语言层面和软件层面了。

2.C++中如何解决并发问题?

显然通过多进程来实现并发是不可靠的,C++中采用多线程实现并发。

线程算是一个底层的,传统的并发实现方法。C++11中除了提供thread库,还提供了一套更加好用的封装好了的并发编程方法。

C++中更高端的并发方法:(此内容因本人暂未理解,暂时搁置,待理解之时会前来更新,请读者朋友谅解)

3.C++中多线程的语言实现?

这里以一个典型的示例——求和函数来讲解C++中的多线程。

单线程版:

#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
 
int GetSum(vector<int>::iterator first,vector<int>::iterator last)
{
    return accumulate(first,last,0);//调用C++标准库算法
}
 
int main()
{
    vector<int> largeArrays;
    for(int i=0;i<100000000;i++)
    {
        if(i%2==0)
           largeArrays.push_back(i);
        else
            largeArrays.push_back(-1*i);
    }
    int res = GetSum(largeArrays.begin(),largeArrays.end());
    return 0;
}

多线程版:

#include <iostream>
#include <vector>
#include <algorithm>
#include <thread>
using namespace std;
 
//线程要做的事情就写在这个线程函数中
void GetSumT(vector<int>::iterator first,vector<int>::iterator last,int &result)
{
    result = accumulate(first,last,0); //调用C++标准库算法
}
 
int main() //主线程
{
    int result1,result2,result3,result4,result5;
    vector<int> largeArrays;
    for(int i=0;i<100000000;i++)
    {
        if(i%2==0)
            largeArrays.push_back(i);
        else
            largeArrays.push_back(-1*i);
    }
    thread first(GetSumT,largeArrays.begin(),
        largeArrays.begin()+20000000,std::ref(result1)); //子线程1
    thread second(GetSumT,largeArrays.begin()+20000000,
        largeArrays.begin()+40000000,std::ref(result2)); //子线程2
    thread third(GetSumT,largeArrays.begin()+40000000,
        largeArrays.begin()+60000000,std::ref(result3)); //子线程3
    thread fouth(GetSumT,largeArrays.begin()+60000000,
        largeArrays.begin()+80000000,std::ref(result4)); //子线程4
    thread fifth(GetSumT,largeArrays.begin()+80000000,
        largeArrays.end(),std::ref(result5)); //子线程5
 
    first.join(); //主线程要等待子线程执行完毕
    second.join();
    third.join();
    fouth.join();
    fifth.join();
 
    int resultSum = result1+result2+result3+result4+result5; //汇总各个子线程的结果
 
    return 0;
}

C++11中引入了多线程技术,通过thread线程类对象来管理线程,只需要#include 即可。thread类对象的创建意味着一个线程的开始。

thread first(线程函数名,参数1,参数2,…);每个线程有一个线程函数,线程要做的事情就写在线程函数中。

根据操作系统上的知识,一个进程至少要有一个线程,在C++中可以认为main函数就是这个至少的线程,我们称之为主线程。而在创建thread对象的时候,就是在这个线程之外创建了一个独立的子线程。这里的独立是真正的独立,只要创建了这个子线程并且开始运行了,主线程就完全和它没有关系了,不知道CPU会什么时候调度它运行,什么时候结束运行,一切都是独立,自由而未知的。

因此下面要讲两个必要的函数:join()和detach()

如:thread first(GetSumT,largeArrays.begin(),largeArrays.begin()+20000000,std::ref(result1)); first.join();

这意味着主线程和子线程之间是同步的关系,即主线程要等待子线程执行完毕才会继续向下执行,join()是一个阻塞函数。

而first.detach(),当然上面示例中并没有应用到,则表示主线程不用等待子线程执行完毕,两者脱离关系,完全放飞自我。这个一般用在守护线程上:有时候我们需要建立一个暗中观察的线程,默默查询程序的某种状态,这种的称为守护线程。这种线程会在主线程销毁之后自动销毁。

C++中一个标准线程函数只能返回void,因此需要从线程中返回值往往采用传递引用的方法。我们讲,传递引用相当于扩充了变量的作用域。

我们为什么需要多线程,因为我们希望能够把一个任务分解成很多小的部分,各个小部分能够同时执行,而不是只能顺序的执行,以达到节省时间的目的。对于求和,把所有数据一起相加和分段求和再相加没什么区别。

创建/启动一个新线程

无参线程

从hello world开始:

#include <iostream>
#include <thread>
void hello(){
	std::cout<<"Hello Concurrent World!"<<endl;
}

int main(){
	std::thread t(hello);
	t.join();
}

众所周知,主线程的入口函数为main函数。同样的,子线程也需要有一个入口函数hello,并在调用子线程的构造函数的时传给子线程std::thread t(hello);。子线程在创建的那一刻就启动了。

我们希望子线程输出字符串的时候,主线程即使运行完也要等待它,两个线程一起结束,所以调用join方法。

此外,这个函数hello可以通过重载Function call实现成仿函数的形式:

class hello{
public:
	void operator()(){
		std::cout<<"Hello Concurrent World!"<<std::endl;
	}
};

提供给thread实例的函数对象会复制到新线程的存储空间中,函数对象的执行和调用都在线程的内存空间中执行。传递函数对象时要注意语法的格式:

std::thread t(hello());

上面的代码会被c++编译器将整句话整体解析为函数声明,而不是类型对象的定义!!!编译器认为这里声明了一个函数名为t的函数,返回值为std::thread,函数带有一个参数(一个无参数的函数指针,返回类型为hello)。解决方案如下:

std::thread t((hello())); //1
std::thread t{hello()}; //2

另一种方案是使用lambda表达式:

std::thread t([]{
	std::cout<<"Hello Concurrent World!"<<std::endl;
});

此外,可以将类的普通成员函数作为子线程入口函数:

class Hello{
public:
	void hello(){
		std::cout<<"Hello Concurrent World!"<<std::endl;
	}
};
int main(){
	Hello h;
	std::thread t(&Hello::hello,&h);
	t.join();
	return 0;
}

这种方式需要一个类的实例h。子线程将&Hello::hello作为线程函数,h的地址作为指针对象提供给函数。
有参线程

或者叫向线程传递参数:

void shared_print(std::string &msg,int idx){
	std::cout<<msg<<":"<<idx<<std::endl;
}
void func(std::string &msg,int N){
	for(int i=0;i<N;i++){
		shared_print(msg,i);
	}
}
int main(){
	std::thread t(func,"sub thread ",4);
	t.join();
	return 0;
}

注意:
这里thread t的第二个参数传入的是字符串字面值const char *,会隐式转换为string。如果担心隐式转换可能会崩溃,从而导致空悬指针,可以使用显式转换std::thread t(func,std::string("sub thread "),4);
对于如下代码:

void factorial(int &x){
	for(int i=x-1;i>0;i--){
		x *= i;
	}
}
int main(){
	int x=4;
	std::thread t(factorial,x); //error:未能使函数模板“unknown-type std::invoke(_Callable &&,_Types &&...)”专用化
	std::cout<<""<<x<<std::endl;
	return 0;
}

即使默认参数是引用形式,也会拷贝到线程独立内存中(vs2015无法编译通过)。
此时需要std::ref将参数转换为引用的形式:

thread t(factorial,std::ref(t));

可以使用移动语义move将参数的所有权转移:

class Hello {
public:
	void hello(string s) {
		std::cout << "Hello Concurrent World!" <<s<< std::endl;
	}
};
int main() {
	Hello h;
	string s = "\\nfun";
	std::thread t(&Hello::hello, &h,move(s));
	cout << s << endl;  //此时s为空
	t.join();
	return 0;
}

进程

程序运行基础部分

   1、时钟中断:即为多道程序设计模型的理论基础。 并发时,任意进程在执行期间都不希望放弃cpu。因此系统需要一种强制让进程让出cpu资源的手段。时钟中断有硬件基础作为保障,对进程而言不可抗拒。 操作系统中的中断处理函数,来负责调度程序执行。

  2、 CPU和MMU

        2.1CPU运算过程

中央处理器(CPU)

   缓存:位于CPU与内存之间的临时存储器。现在大部分的处理器都有二级或者三级缓存,缓存又可以分为指令缓存和数据缓存,指令缓存用来缓存程序的代码,数据缓存用来缓存程序的数据。 

   预取器:从缓存中读取数据,交给译码器解析。

   译码器:对数据进行解析,并送给数据逻辑单元执行。

   寄存器堆:寄存器部分,CPU所谓32位和64位是针对寄存器而言的,32位是4字节大小,64位是8字节,寄存器位于CPU内部,CUP直接从寄存器中读取数据,对数据进行计算和存储,一个寄存器的大小是4KB,32位通用寄存器有八个,eax, ebx, ecx, edx, esi, edi, ebp, esp(他们主要用作逻辑运算、地址计算和内存指针),6个段寄存器(es、cs、ss、ds、fs和gs),1个指令指针寄存器(eip),1个标志寄存器(EFlags)。

   算术逻辑单元:CPU的运算部分,只支持+和<<运算,由译码器解析完后,把需要运算的数据放入到寄存器中,算数逻辑单元对数据进行+或<<运算,计算完在把结果回写到寄存器中,然后再写入缓存或内存中。

   

         2.2、MMU内存管理单元

   MMU位于CPU内部,它的作用是:

   1、用来管理虚拟内存与实际的物理地址之间的映射的,没有这个硬件,就不能实现虚拟内存管理。

   2、设置修改内存访问级别。MMU在完成虚拟地址与物理地址之间映射的同时还会为这段内存设置访问级别,因为虚拟内存本身分用户空间和内核空间,他们的访问权限是不一样的。Intel架构下CPU有四个访问级别0,1,2,3级,级别越高,权限越低,linux系统通常只用了0级和三级,对应内核空间(0级)和用户空间(3级)。在进程调用系统函数的时候,需要把访问权限从用户空间转到内核空间,这也是有MMU完成的。

                                                                             内存管理单元MMU

   3、进程控制块PCB

   PCB(进程控制块): 在*nix操作系统中,进程创建后会自动在内存中产生一个空间存放进程信息,称为PCB进程控制块,每个进程在内核中都有一个进程控制块(PCB)来维护进程相关的信息,是以结构体的形式存在的,Linux内核的进程控制块是task_struct结构体,定义在/usr/src/linux-headers-4.15.0-33/include/linux/sched.h文件中,用 grep -r "task_struct {" /usr/  命令搜索到,可以查看struct task_struct 结构体定义(1224-1657),重点以下几部分:

   * 进程id。系统中每个进程有唯一的id,在C语言中用pid_t类型表示,其实就是一个非负整数。

   * 进程的状态,有就绪、运行、挂起、停止等状态。

   * 进程切换时需要保存和恢复的一些CPU寄存器。

   * 描述虚拟地址空间的信息。

   * 描述控制终端的信息。

   * 当前工作目录(Current Working Directory)。

  * umask掩码。

  * 文件描述符表,包含很多指向file结构体的指针。

  * 和信号相关的信息。

 * 用户id和组id。

 * 会话(Session)和进程组。(会话用来统一管理进程的)

 * 进程可以使用的资源上限(Resource Limit)。

程序和进程

   程序:是指编译好的二进制文件,在磁盘上,不占用系统资源(cpu、内存、打开的文件、设备、锁....)

   进程:是一个抽象的概念,指程序在计算机中的一次执行过程,进程是一个动态的过程描述,占有计算机的资源,有一定的生命周期,在内存上执行。

   并发:在操作系统中,一个时间段中有多个进程都处于已启动运行到运行完毕之间的状态。但任一个时刻点上仍只有一个进程在运行,各个进程相互争夺CPU时间片。

   并行:多个计算机核心在同时处理多个任务,这多个任务间是并行关系

进程的创建流程
1.用户空间运行一个程序,发起进程的创建
2.操作系统接受用户申请,开启进行创建
3.操作系统分配计算机资源,确定进程状态
4.将新创建的进程交给用户(应用层)使用

创建进程的函数

    fork()函数:创建一个子进程。

    pid_t fork(void);        失败返回-1;成功返回:① 父进程返回子进程的ID(非负)     ②子进程返回 0

    pid_t类型表示进程ID,但为了表示-1,它是有符号整型。(0不是有效进程ID,init最小,为1)

    注意返回值,不是fork函数能返回两个值,而是fork后,fork函数变为两个,父子需【各自】返回一个。

    系统调用fork()函数允许一个进程(父进程)创建一个新的进程(子进程),具体的做法是,新的子进程几近于对父进程的翻版:子进程获得父进程的栈,数据段,堆和执行文本段的拷贝,可将此视为把父进程一分为二,fork也因此得名。

demo1: 简单的进程创建

#include <stdio.h>
 
#include <unistd.h>
 
#include <stdlib.h>
 
 
int var = 34;
 
int main(void)
 
{
 
pid_t pid;
 
 
 
pid = fork();
 
if (pid == -1 ) {
 
perror("fork");
 
exit(1);
 
} else if (pid > 0) {
 
sleep(2);
 
var = 55;
 
printf("I'm parent pid = %d, parentID = %d, var = %d\\n", getpid(), getppid(), var);
 
} else if (pid == 0) {
 
var = 100;
 
printf("child pid = %d, parentID=%d, var = %d\\n", getpid(), getppid(), var);
 
}
 
printf("var = %d\\n", var);
 
return 0;
 
}

 

进程、线程的区别与联系

一个程序至少产生一个进程,一个进程至少产生一个线程,线程再映射到内核线程,在处理器上完成计算任务。
进程与线程的区别,关键就在于它们独立拥有的资源不同,因此就体现出了不同的特性,主要有以下几方面的不同:
(1)资源
进程作为除CPU以外系统资源的分配单位,每个进程都拥有独立的地址空间和资源,因此一个进程崩溃不会引起其他进程的运行,就好像是不同程序创建的不同进程互不影响一样;
线程作为CPU的分配单位,是被系统独立调度和分派的基本单位,本身仅拥有CPU中的寄存器和线程函数调用需要的堆栈区这一点点资源,其他资源如代码、公有数据、文件描述符等都是与其他线程共享的进程资源,所以一个线程崩溃,整个进程都崩溃。
(2)系统开销
由于每个进程都拥有独立的地址空间和资源,因此进程的创建和撤销都需要资源的分配和回收,系统开销大;
每个线程仅仅拥有一点必不可少的资源,所以创建和撤销都很快。
(3)通信方式
进程之间主要有6种通信方式:管道、信号、消息队列、共享内存、信号量、socket;
线程之间主要有3种通信方式:锁机制(包括互斥锁、条件变量、读写锁)、信号量机制、信号机制。
线程间的通信目的主要是用于线程同步,所以线程没有像进程通信中的用于数据交换的通信机制。
通信方式的内容写得不够详细,后续再补充,下面这些是暂时记录的内容:

信号量:一个初始值为N的信号量允许N个线程并发访问。线程访问资源时首先获取信号量锁,进行如下操作:
1. 将信号量的值减1;
2. 如果信号量的值小于0,则进入等待状态,否则继续执行;
访问资源结束之后,线程释放信号量锁,进行如下操作:
1. 将信号量的值加1;
2. 如果信号量的值小于1(等于0),唤醒一个等待中的线程;
同步:多个事件一起开始,一起结束(一刀切);
异步:多个事件各走各的,完成了通知一下控制中心;
阻塞:所申请资源被占用、启动IO传输未完成,必须等待资源或事件完成才可以进行下一步称为阻塞;
挂起:挂起是主动的,一般需要用挂起函数进行操作,若没有resume的动作,则此任务一直不会ready,而阻塞是因为资源被其他任务抢占而处于休眠态。

以上是关于多线程与多进程的主要内容,如果未能解决你的问题,请参考以下文章

Python基础教程之多线程与多进程

多线程与多进程的实现

多线程与多进程

编程思想之多线程与多进程——Java中的多线程

多进程 MPI 与多线程 std::thread 性能

基于Python的多线程与多进程