系列文章目录
第一章 基本概念:并发、进程与线程
目录
前言
工作中难免会用到多进程和多线程,收藏别人的博客不如自己整理出一篇。
一、并发
最简单和最基本的并发,是指两个或更多独立的活动同时发生。
并发在生活中随处可见,我们可以一边走路一边说话,也可以两只手同时作不同的动作,还有我们每个人都过着相互独立的生活——当我在游泳的时候,你可以看球赛,等等。
计算机领域的并发指的是在单个系统里同时执行多个独立的任务,而非顺序的进行一些活动。
1.并发与并行
计算机领域里,并发不是一个新事物:很多年前,一台计算机就能通过多任务操作系统的切换功能,同时运行多个应用程序;高端多处理器服务器在很早就已经实现了真正的并行计算。
以前,大多数计算机只有一个处理器,具有单个处理单元(processing unit)或核心(core)。这种机器只能在某一时刻执行一个任务,不过它可以在单位时间内进行多次任务切换。通过“这个任务做一会,再切换到别的任务,再做一会儿”的方式,让任务看起来是并行执行的。这种方式称为“任务切换(task switching)”。因为任务切换得太快,以至于无法感觉到任务在何时会被暂时挂起,而切换到另一个任务。任务切换会给用户和应用程序造成一种“并发的假象”。
多处理器计算机用于服务器和高性能计算已有多年。基于单芯多核处理器(多核处理器)的台式机,也越来越大众化。无论拥有几个处理器,这些机器都能够真正的并行多个任务。我们称其为“硬件并发(hardware concurrency)”。
如果一个系统支持多个动作同时存在,那么这个系统就是一个并发系统。如果这个系统还支持多个动作(物理时间上)同时执行,那么这个系统就是一个并行系统。
你可能已经看出,“并行”其实是“并发”的子集。它们的区别在于是否具有多个处理器。如果存在多个处理器同时执行多个线程,就是并行。在不考虑处理器数量的情况下,我们统称之为“并发”。
2.为什么需要并发
主要原因有两个:关注点分离(SOC)和性能。
2.1 为了分离关注点
通过将相关的代码与无关的代码分离,可以使程序更容易理解和测试,从而减少出错的可能性。即使一些功能区域中的操作需要在同一时刻发生的情况下,依旧可以使用并发分离不同的功能区域;若不显式地使用并发,就得编写一个任务切换框架,或者在操作中主动地调用一段不相关的代码。
在这种情况下,线程的数量不再依赖CPU中的可用内核的数量,因为对线程的划分是基于概念上的设计,而不是一种增加吞吐量的尝试。
2.2 为了性能
一方面,大型的软件项目常常包含非常多的任务需要处理。例如:对于大量数据的数据流处理,或者是包含复杂GUI界面的应用程序。如果将所有的任务都以串行的方式执行,则整个系统的效率将会非常低下,应用程序的用户体验会非常的差。
另一方面,CPU频率增长在最近的十年已经基本停滞,CPU性能以更多核的形式在增长。大家会发现曾经有过一段时间CPU的频率从3G到达4G,但在这之后就停滞不前了。因此最近的新款CPU也基本上都是3G左右的频率。相应的,CPU以更多核的形式在增长。目前的Intel i7有8核的版本,Xeon处理器达到了28核。并且,最近几年手机上使用的CPU也基本上是4核或者8核的了。
由此,掌握并发编程技术,利用多处理器来提升软件项目的性能将是软件工程师的一项基本技能。
3. 什么时候不使用并发
基本上,不使用并发的唯一原因就是,收益比不上成本。
使用并发的代码在很多情况下难以理解,因此编写和维护的多线程代码就会产生直接的脑力成本,同时额外的复杂性也可能引起更多的错误。除非潜在的性能增益足够大或关注点分离地足够清晰,能抵消所需的额外的开发时间以及与维护多线程代码相关的额外成本(代码正确的前提下);否则,别用并发。
同样地,性能增益可能会小于预期;因为操作系统需要分配内核相关资源和堆栈空间,所以在启动线程时存在固有的开销,然后才能把新线程加入调度器中,所有这一切都需要时间。如果在线程上的任务完成得很快,那么任务实际执行的时间要比启动线程的时间小很多,这就会导致应用程序的整体性能还不如直接使用“产生线程”的方式。
此外,线程是有限的资源。运行越多的线程,操作系统就需要做越多的上下文切换,每个上下文切换都需要耗费本可以花在有价值工作上的时间。如果让太多的线程同时运行,则会消耗很多操作系统资源,从而使得操作系统整体上运行得更加缓慢。不仅如此,因为每个线程都需要一个独立的堆栈空间,所以运行太多的线程也会耗尽进程的可用内存或地址空间。对于一个可用地址空间为4GB(32bit)的平坦架构的进程来说,这的确是个问题:如果每个线程都有一个1MB的堆栈(很多系统都会这样分配),那么4096个线程将会用尽所有地址空间,不会给代码、静态数据或者堆数据留有任何空间。即便64位(或者更大)的系统不存在这种直接的地址空间限制,但其他资源有限:如果你运行了太多的线程,最终也是出会问题的。尽管线程池(参见第9章)可以用来限制线程的数量,但这并不是灵丹妙药,它也有自己的问题。
4.并发的途径
4.1 多进程并发
将应用程序分为多个独立的进程,它们在同一时刻运行。独立的进程可以通过进程间常规的通信渠道传递讯息(信号、套接字、文件、管道等等)。
不过,这种进程之间的通信通常不是设置复杂,就是速度慢,这是因为操作系统会在进程间提供了一定的保护措施,以避免一个进程去修改另一个进程的数据。还有一个缺点是,运行多个进程所需的固定开销:需要时间启动进程,操作系统需要内部资源来管理进程,等等。
当然,以上的机制也不是一无是处:操作系统在进程间提供附加的保护操作和更高级别的通信机制,意味着可以更容易编写安全(safe)的并发代码。使用独立的进程,实现并发还有一个额外的优势———可以使用远程连接(可能需要联网)的方式,在不同的机器上运行独立的进程。虽然,这增加了通信成本,但在设计精良的系统上,这可能是一个提高并行可用性和性能的低成本方式。
4.2 多线程并发
并发的另一个途径,在单个进程中运行多个线程。线程很像轻量级的进程:每个线程相互独立运行,且不同线程可以执行不同的指令。但是,进程中的所有线程都共享地址空间,并且大部分数据可以被所有线程访问:全局变量仍然是全局的,对象和数据的指针或引用可以在线程之间传递。
地址空间共享,以及缺少线程间数据的保护,使用多线程相关的系统开销远远小于使用多进程。不过,程序员必须确保每个线程所访问到的共享数据是一致的,在编写代码时需要对线程通信做大量的工作。
二、进程与线程
进程与线程是操作系统的基本概念。无论是桌面系统:MacOS,Linux,Windows,还是移动操作系统:Android,iOS,都存在进程和线程的概念。
1. 为什么要有线程
在早期的操作系统中并没有线程的概念,进程是拥有资源和独立运行的最小单位,也是程序执行的最小单位。任务调度采用的是时间片轮转的抢占式调度方式,而进程是任务调度的最小单位,每个进程有各自独立的内存空间,使得各个进程之间内存地址相互隔离。
后来,随着计算机行业的发展,程序的功能设计越来越复杂,我们的应用中同时发生着多种活动,其中某些活动随着时间的推移会被阻塞,比如网络请求、读写文件(也就是IO操作)。我们自然而然地想着能不能把这些应用程序分解成更细粒度、能准并行运行多个顺序执行实体。
需要多线程还有一个重要的理由就是:每个进程都有独立的代码和数据空间(程序上下文),程序之间的切换会有较大的开销;线程可以看做轻量级的进程,同一类线程共享代码和数据空间,线程之间切换的开销小。所以线程的创建、销毁、调度性能远远优于进程。
在引入多线程模型后,进程和线程在程序执行过程中的分工就相当明确了:进程负责分配和管理系统资源;线程负责CPU调度运算,也是CPU切换时间片的最小单位。对于任何一个进程来讲,即便我们没有主动去创建线程,进程也是默认有一个主线程的。
2. Windows下进程和线程的实现方式
在Windows等其他操作系统中,进程拥有一个进程描述符,描述一些地址空间和打开的文件等共享资源,进程中包含指向不同线程的指针。
Windwos下通过CreateProcess()创建一个新的进程,传递可执行文件存储地址、可执行文件命令行输入、环境变量块等。
Windows下通过CreateThread()创建一个新的线程,传递线程函数的入口地址和调用参数给新建的线程,然后新线程就开始执行了。
Windows下一个典型的线程拥有自己的堆栈、寄存器(包括程序计数器PC,用于指向下一条应该执行的指令在内存中的位置),而代码段、数据段、打开文件这些进程级资源是同一进程内多个线程所共享的。
3. Linux下进程和线程的实现方式
在Linux 内核里面,无论是进程还是线程统一都叫任务(Task),由一个统一的进程描述符task_struct 进行管理。这个task_struct 数据结构非常复杂,囊括了进程管理生命周期中的各种信息。
Linux下不区分线程和进程,没有为线程设置专门的数据结构,也没有专门的线程调度算法,都会分配一个task_struct。在Linux内核看来,线程就是一个进程,只是一个和其他进程共享资源的特殊进程而已。
Linux通过fork系统调用建立新的进程,也就是新的进程要通过老的进程复制自身得到。当进程fork的时候,Linux在内存中开辟出一片新的内存空间给新的进程,并将老的进程空间中的内容复制到新的空间中,此后两个进程同时运行。老进程成为新进程的父进程(parent process),而相应的,新进程就是老的进程的子进程(child process)。fork通常作为一个函数被调用,可以在进程执行的任何阶段被调用。fork调用后返回的PID等于0时,说明该进程为子进程;返回的PID大于0时,说明该进程为父进程。
那么Linux中的进程和线程如何区分呢?线程不是一个完全由内核实现的机制,它是由内核态和用户态合作完成的。创建进程的话,调用的系统调用是 fork,会将五大结构 files_struct、fs_struct、sighand_struct、signal_struct、mm_struct 都复制一遍,从此父进程和子进程各用各的数据结构。而创建线程的话,调用的是系统调用 clone,五大结构仅仅是引用计数加一,也即线程共享进程的数据结构。
参考文献
浅谈linux和windows的线程机制的区别_linux windows 线程区别-优快云博客
总结
简单学习整理了进程和线程的基本概念。