首页
学习
活动
专区
工具
TVP
发布
精选内容/技术社群/优惠产品,尽在小程序
立即前往

万字长文把云计算服务软件基础框架的构建给讲的明明白白的!

云计算服务软件基础框架的构建

前面提到,构建了云计算服务软件的基础框架后,开发人员在开发新的云计算服务软件时,只需要根据具体任务修改“任务执行”模块即可,而通用模块是可以直接复用的。

本节将介绍具体的构建方法,主要从4个方面进行深入讲解,分别是进程与线程、线程的同步与互斥、线程模型和软件结构。

进程与线程

云计算服务软件在运行期间需要同时做多件事情,例如,在执行任务的同时需要监听任务变更指令。而一个软件想要同时做多件事情,则必然会涉及多线程。本小节先介绍一下进程与线程的相关内容。

注意:进程与线程的相关内容在不同操作系统或不同版本系统内核上会有所区别,但一般是大同小异的。本节以CentOS操作系统为例(Linux内核的操作系统都适用)。

进程是软件运行时的实例,进程拥有独立的系统资源,一般来说,进程与进程间是互不影响的。运行一个软件时,系统会产生一个进程,同一软件被同时运行多次的话,则系统会产生多个进程,而这多个进程虽然是执行相同的任务,但它们是互不影响的。当然,一个程序也可以创建多个进程(子进程)以实现并行执行多任务的目的。

线程是进程中的实际运作单位,进程是线程的容器,一个进程包含一个或多个线程,多个线程可并行执行不同的任务。同一进程中的多个线程共享该进程的全部系统资源(如虚拟地址空间、文件描述符、信号处理等),但每个线程都有各自的调用栈、寄存器环境及线程本地存储等。同一进程的多个线程可共用部分数据,如全局变量、堆空间的变量等。线程的相关操作如代码5.1所示:

代码5.1 线程的相关操作

这里需要说明一个问题,一个程序可以通过创建多个进程以应对并行执行多任务的需求,而多线程也是为了应对并行任务的需求。那么,为什么还需要多线程呢?

在早期的操作系统中,确实没有线程的概念,进程是实际的运作单位,多进程也确实能满足并行执行多任务的需求。但是,对于一个需要并行执行多个任务的程序而言,只有进程的操作系统有两个弊端,一是进程间是独立系统资源的,这样会造成一些资源浪费;二是进程间是相互隔离的,进程间通信是一件很烦琐的事情。

另外,程序使用多线程还有一个好处,当某个线程发生意外崩溃时,如果不做特殊处理,那么该线程的所属进程会自动被销毁,同一进程的其他线程也会自动被销毁。如果是一个程序创建了多个进程的话,则当其中一个进程意外崩溃时,其他进程还会继续运行,而这些继续运行的进程往往已经失去其运行的意义,只能给系统带来不必要的负担。

因此,一般情况下,程序是使用多线程来实现并行执行多任务的,如图5.9所示。其中,一个进程中的多个线程执行各自的任务。另外,线程间的协助通常是通过获取或操作共享数据完成的。

图5.9 使用多线程实现并行执行多任务的程序

线程同步

在一个多线程的程序当中,线程间的协助一般是通过获取或操作共享数据完成的。但是,当某个线程修改一个共享数据时,如果存在其他线程也同时读取或修改这个共享数据的话,则可能会造成数据不一致的情况。为了避免这样的问题,需要对这些线程进行同步。

线程同步需要解决两个问题,一是确保共享资源的一致性,当有一个线程正在操作共享资源时,确保其他线程不能对该资源进行操作,直到该线程结束操作;二是确保线程间执行顺序的合理性,当一个线程需要依赖其他线程所产生的数据,而这个数据却未被生产时,则该线程进入等待状态,直到其他线程产生该数据后再继续运行。

针对问题一,解决的工具通常是互斥锁;针对问题二,解决的方法通常是使用互斥锁和条件变量。当然,这些工具或方法不是唯一的,但这些方法是比较常用的,足够应对需要解决线程同步的大多数场景。

1.互斥锁

互斥锁本质上是一把锁,线程在操作共享资源前对互斥锁进行上锁,在操作完共享资源后对互斥锁进行解锁。在互斥锁被上锁期间,任何其他尝试再次对互斥锁上锁的线程将会被阻塞,直到该互斥锁被释放。

说明:互斥锁的本质是一把锁,但除了互斥锁以外,还有其他锁,如读写锁等。读写锁允许更高的并行性,适用于“读远大于写”的情况,但也更为复杂,所以这里不对其展开介绍。

通过对互斥锁上锁实现线程同步的流程如图5.10所示。其中,线程1和线程2使用的是同一个互斥锁,线程2等待线程1释放互斥锁期间是被阻塞的(不进行其他操作)。另外,如果释放互斥锁时有多个等待的线程,则只有一个线程可以对互斥锁上锁并继续运行,其他线程继续等待。而这个获得加锁权的线程可以认为是随机产生的,与等待的先后顺序没有直接关系。

图5.10 通过对互斥锁上锁实现线程同步

互斥锁的相关操作如代码5.2所示,其中,多个线程必须要对同一个互斥锁进行操作才能实现线程同步。另外,互斥锁在使用之前必须要被初始化。

代码5.2 互斥锁的相关操作

使用互斥锁需要尽量避免死锁的情况,发生死锁后,可能会导致多个线程永远处于等待状态。一般出现死锁状态有两种情况,一是线程对同一个互斥锁连续加锁两次;二是线程涉及多个互斥锁时也可能会发生死锁,例如,线程1锁住第一个互斥锁且等待第二个互斥锁的同时,线程2锁住第二个互斥锁且等待第一个互斥锁。对于第一种情况,一般是一些不必要的人为错误;针对第二种情况,可以通过控制对多个互斥锁加锁和解锁的顺序解决。但有些时候,由于复杂的程序结构使得对多个互斥锁的加锁和解锁进行排序是困难的,此时可尝试使用pthread_mutex_trylock()函数对互斥锁进行加锁,如果互斥锁已经上锁,则此函数会返回失败,线程不会被阻塞。

注意:线程同步在现实编程中是非常复杂的,而死锁是其复杂性的表现。但是,不要把死锁问题放大,从而使用一些奇怪的方法实现线程同步。

2.条件变量

条件变量是线程同步的另外一种机制,条件变量可用于阻塞线程,直到某种条件成立。使用条件变量主要包含两个动作,一个是线程等待“条件变量被触发”而被阻塞;另一个是线程触发“条件成立”(唤醒被阻塞的线程)。使用条件变量能替代“不断轮询条件是否成立”等浪费性能的做法。一般情况下,条件变量需要与互斥锁结合使用,作为互斥锁的补充。

条件变量实际上是一个线程被唤醒的变量,而线程是否进入等待状态,则需要根据其他条件来判断。使用条件变量和互斥锁实现线程同步的流程如图5.11所示。其中,线程1由于依赖数据未产生而进入等待状态(等待条件变量触发),线程2产生线程1依赖的数据后触发条件变量(唤醒线程1,由于线程2占用互斥锁,所以线程1继续处于等待状态),当线程2释放互斥锁后,线程1对互斥锁加锁并继续运行。

图5.11 使用条件变量和互斥锁实现线程同步

条件变量和互斥锁的相关操作如代码5.3所示,其中,一般使用while(而不是if)判断是否进入等待状态,因为等待条件变量触发的线程可能不止一个,但条件变量触发时可能会唤醒多个等待条件变量触发的线程,使用while可以让被唤醒的线程重新判断进入等待状态的条件,避免由于其他线程消耗了等待的数据而发生错误。当然,如果确保等待的线程只会有一个,则可以用if判断是否进入等待状态。

说明:一般情况下,函数pthread_cond_signal()只会唤醒等待该条件变量的某个线程,但其内部为了简化实现,可能会唤醒不止一个线程。另外,函数pthread_cond_broadcast()可以唤醒等待该条件变量的所有线程。

代码5.3 条件变量和互斥锁的相关操作

3.生产者与消费者模型

生产者与消费者问题是线程同步的经典问题,而生产者与消费者模型适用于大部分线程同步场景。生产者与消费者问题指的是,生产者负责生产货物并把货物存放到仓库,消费者从仓库中取得货物并消耗货物,生产者和消费者同时执行各自的任务且不直接通信。对应的生产者与消费者模型如图5.12所示。

图5.12 生产者与消费者模型

其中,当仓库为空时(无货物可消耗),消费者需要等待货物生产后再继续消耗货物;当仓库已满时,生产者需要等待仓库有空闲位置后再继续生产货物。

注意:生产者和消费者都可以是多个,但它们各自生产或消耗货物,它们之间不直接通信。生产者和消费者是否暂停任务取决于仓库的空满状态。另外,生产者与消费者模型不单单能描述线程同步问题,还可以描述应用程序与应用程序间、服务与服务间的协助问题。

在程序当中,仓库相当于一个全局的数据空间(数组、容器或链表),生产者相当于产生数据的线程,消费者相当于使用数据的线程。生产者线程负责生产数据并把数据放到仓库当中,消费者线程负责把数据从仓库中取出并使用该数据。生产者和消费者模型的代码实现如代码5.4所示,其中,代码中使用了条件变量和互斥锁,生产者线程和消费者线程都可以是多个。

代码5.4 生产者与消费者模型的代码实现

典型的生产者与消费者模型是建立在数据空间有限的基础上的,而且需要确保生产的数据都能被正常消耗掉。但在一些流式的处理上(如直播转码等流式处理),消费者线程可能会因为一些不稳定因素(如网络等)造成处理时间过长的情况(导致数据仓库被填满),而生产者线程是需要实时获取数据的(生产者线程不能因为仓库被填满而等待)。

在这种生产者线程不能因为数据仓库被填满而进入等待状态的情况下,可以做环式的数据仓库(允许数据被覆盖),但这种方式对于“数据长度不一致”的情况来说,当发生数据覆盖时,很难推算出新的读数据位置,所以如果实际情况允许,可以直接清空数据仓库并归零读写游标。

说明:也可以通过开辟临时的数据仓库解决以上问题,但这样的方式一般是不被提倡的,因为临时数据仓库机制是复杂的,而且可能会造成内存枯竭等问题。

线程模型

在了解了线程与进程以及线程与线程同步的相关介绍后,我们回到构建云计算服务软件的问题上。云计算服务软件在运行期间需要同时做多件事情,例如,在执行任务的同时需要监听任务变更指令。而一个软件想要同时做多件事情,必然会涉及多线程。因此,构建云计算服务软件基础框架的第一步应该是建立线程模型,在线程模型之上,才能更有效地对各个模块进行分而治之。设计线程模型一般分两步完成,首先是划分线程和明确依赖关系,然后明确线程间的同步方式。

1.划分线程和明确依赖关系

在5.2.1小节中把云计算服务软件分为5个模块,即任务获取模块、变更指令获取模块、任务执行模块、进度汇报模块和运行状态汇报模块。对应的,可以直接把这5个模块分别做成5个独立线程,但由于进度汇报模块和运行状态汇报模块都是定时任务(定时汇报),可以把这两个模块合并成一个线程,所以应该划分以下4个独立线程:

任务获取线程,对应任务获取模块;

变更指令获取线程,对应变更指令获取模块;

任务执行线程,对应任务执行模块;

汇报线程,对应进度汇报模块和运行状态汇报模块。

说明:任务获取模块和任务执行模块也可以合成一个线程,因为在执行任务期间不需要再获取任务。但是由于任务获取模块是通用模块,而任务执行模块会根据不同业务做变更,所以为了保持任务获取模块的纯粹性,把任务获取模块和任务执行模块分为两个线程执行任务。

线程模型的线程划分和依赖关系如图5.13所示。其中,以任务执行线程为中心,任务获取线程、变更指令获取线程和汇报线程都只与任务执行线程产生依赖关系。

图5.13 线程模型的线程划分和依赖关系

2.明确线程间的同步方式

根据线程的依赖关系,可以将线程间的同步分为3个部分讲解,即任务获取线程与任务执行线程、汇报线程与任务执行线程、变更指令获取线程与任务执行线程。

(1)任务获取线程与任务执行线程的同步。“任务获取线程”监听“任务池”并获取任务后,先向“后端应用程序”通知任务开始,然后把任务写入“任务变量”并唤醒“任务执行线程”,“任务执行线程”工作期间,“任务获取线程”会处于等待状态,直到“任务执行线程”结束工作。“任务执行线程”一开始处于等待状态,直到被“任务获取线程”唤醒,“任务执行线程”从“任务变量”获取任务并执行,执行结束后,“任务执行线程”将唤醒“任务获取线程”并进入等待状态。

“任务获取线程”与“任务执行线程”的同步流程如图5.14所示。其中,“任务获取线程”通知“后端应用程序”的方式一般是调用“后端应用程序”提供的RESTful API。

图5.14 任务获取线程与任务执行线程的同步流程

说明:任务获取线程在每次任务结束后需要判断任务执行次数是否已经达到消亡次数,如果达到则退出程序,消亡次数根据实际业务场景设定。消亡机制有助于云计算服务软件的持续运行。

在程序中,任务获取线程与任务执行线程的同步总共需要三个函数,分别用于推送任务、获取任务和结束任务,如代码5.5所示,其中任务变量一般为JSON类型,这样能灵活地设置任务参数。

说明:严格来说,JSON不算是C++中的变量类型,它只是一个第三方类。另外,要想在C++中使用JSON需要引用相关的库,一般使用的是JsonCpp。

代码5.5 任务获取线程与任务执行线程同步的代码实现

(2)汇报线程与任务执行线程的同步“汇报线程”是一个定时任务(具体周期按具体业务场景设定),定期从共享的“状态变量”和“任务进度变量”中获取需要的值,并分别向“进度数据池”和“状态数据池”汇报相关的结果;“任务执行线程”在任务开始时和任务结束时会修改“状态变量”,在执行任务过程中会不断修改“任务进度变量”。汇报线程与任务执行线程的同步流程如图5.15所示。

图5.15 汇报线程与任务执行线程的同步流程

注意:任务执行线程的主逻辑一般都是循环,任务进度变量只需要在一个或几个循环周期内修改即可,无须定时修改。

在程序中,汇报线程与任务执行线程的同步分为两个部分:状态变量修改和获取、进度变量修改和获取。汇报线程与任务执行线程同步的代码实现如代码5.6所示,其中,进度变量一般为JSON类型的变量,这样能灵活设置进度参数。

说明:任务获取线程也知道任务状态,所以状态变量的修改也可以由任务获取线程完成。

代码5.6 任务获取线程与任务执行线程同步的代码实现

(3)变更指令获取线程与任务执行线程的同步。“变更指令获取线程”监听“指令池”并获取任务变更指令,若任务变更指令与当前任务ID匹配,则加入变更“指令队列”中,之后,“变更指令获取线程”继续监听指令池;

“任务执行线程”在执行任务过程中不断获取任务变更指令并响应指令。变更指令获取线程与任务执行线程的同步流程如图5.16所示。

图5.16 变更指令获取线程与任务执行线程的同步流

注意:任务执行线程的主逻辑一般都是循环,任务执行线程需要在每个循环周期都查询变更指令。另外,任务执行线程不一定能及时处理变更指令,可能会造成变更指令积压的情况,为了应对这样的场景,变更指令需要放到队列当中。

在程序中,变更指令获取线程与任务执行线程的同步需要两个函数,分别用于变更指令增加和变更指令获取。变更指令获取线程与任务执行线程同步的代码实现如代码5.7所示,其中,变更指令变量一般为JSON类型的变量,这样能灵活设置指令参数。

代码5.7 变更指令获取线程与任务执行线程同步的代码实现

软件结构

在明确了线程模型后,云计算服务软件的骨架也就明确了。在这骨架之上,还需要补充一些通用部分,如错误码文件、通用函数文件、配置文件及日志配置文件等。除此之外,建议把云计算服务软件分成两层,即主逻辑层和模块层,这样能让软件结构更加清晰。云计算服务软件的基础结构如图5.17所示。其中,为了方便管理,线程同步的相关函数及变量都统一放到了同一个地方(线程同步枢纽)。

图5.17 云计算服务软件的基础结构

说明:云计算服务软件分层的目的是分离主逻辑和通用模块,这样不仅能让主逻辑代码更清晰,还能抽离通用模块(复用代码)。

在整个云计算服务软件的结构中,除了任务执行主逻辑以外,大部分都是通用部分。也就是说,除了任务执行主逻辑以外,其他部分即为云计算服务软件的基础框架。

在开发不同业务场景的云计算服务软件时,只需要在这个基础框架之上,编写具体的任务执行逻辑和相关模块即可。至于基础框架的表现形式,可以封装起来(源码不可见),也可以保持代码裸露。

说明:框架是一类软件的基本骨架,构造框架的目的,是为了让开发者只关注具体业务场景的开发,而不需要关心通用部分的代码。至于框架的表现形式,需要根据实际情况而定,封装起来(源码不可见)有助于对外推广,保持代码裸露有利于开发人员查错。

本文给大家讲解的内容是大型网站架构的技术细节:云计算服务软件基础框架的构建

下篇文章给大家讲解的内容是大型网站架构的技术细节:云计算服务架构任务池与指令池的搭建和使用

感谢大家的支持

  • 发表于:
  • 原文链接https://kuaibao.qq.com/s/20230101A00OE200?refer=cp_1026
  • 腾讯「腾讯云开发者社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。

扫码

添加站长 进交流群

领取专属 10元无门槛券

私享最新 技术干货

扫码加入开发者社群
领券