精华内容
下载资源
问答
  • 文末有这本书的知识思维导图 不鸽大家,终于把这本书读完...但这些名词背后的原理是什么,以及究竟是如何应用的,这本书就是吴军博士高深的数学原理,以及数学在各个领域方面是如何应用的讲解的通俗易懂的过程。 .

    文末有这本书的知识思维导图

    不鸽大家,终于把这本书读完,来分享一下读后感。《数学之美》这本书是2012年出版,作者吴军,他的书籍还有《浪潮之巅》、《格局》等也非常有名,吴军博士在语音识别、自然语言处理,特别是统计语言模型的研究上都颇有建树。

    拿到书先看目录,内容包括自然语言处理动态规划算法人工神经网络最大熵模型等都是我们大学接触过或平时挂在嘴边的名词,但这些名词背后的原理是什么,以及究竟是如何应用的,这本书就是吴军博士将高深的数学原理,以及数学在各个领域方面是如何应用的讲解的通俗易懂的过程。

     

    统计语言模型

    前4章其实很有意思,在讲自然语言处理,大家都在说自然语言处理,那到底计算机是怎么处理人类的语言的呢?早期学术界认为必须让机器理解语言,才能处理语言,于是形成了基于规则的自然语言处理技术,但实际上采用统计的方法更简单且准确率更高。第四章谈到了中文分词,因为只有先对句子分词,才能做进一步的自然语言处理,而中文分词也是以统计语言模型为基础的。

     

    几乎所有的自然语言处理问题都可以等价成通信中的解码问题,也就是将收到的信号还原成发送的信息,换句话说,就是在已知接收到的信息o1,o2,o3…的条件下,推测发送的信息s1,s2,s3…,这个问题就变成了一个条件概率的问题,并且是动态的随机过程,每一个时刻的状态都可能和其他状态有关,为了简化问题,就提出了马尔科夫假设:即当前的一个状态只与它前一个状态有关,符合这个假设的过程就是马尔科夫过程,隐马尔科夫模型是马尔科夫过程的一个扩展,隐含马尔科夫模型最初就是用在通信领域里的,后面又推广到了自然语言处理中。

    大师都是如何学习的

    除了用通俗易懂的语言讲解数学知识外,这本书还穿插介绍了一些“大牛”们的经历,讲述了他们的思维方法,在讲贾里尼克的时候,吴军老师提到了孩子教育的问题,其中的一句话我是很认同的,他说:

    书本的内容可以早学,也可以晚学,但是错过了成长阶段却是无法补回来的

    我们这一代,甚至下一代其实都是早早地学习了书本内容,却错过了成长阶段的产物吧,记得电视剧《隐秘的角落》里朱朝阳的妈妈对老师说:学习好就行了,社交那是长大了才应该去做的事情(原句不记得了,大意如此)就刚好是相反的观点,不过教育这个话题就不展开了,要不就跑太远了。

    贾里尼克尝试了不同的职业方向,看起来它的理想在不断地改变,但他通过努力走向成功的志向却一直没变,最终成为了在自然语言处理领域的大师。

    阿米特·辛格博士,谷歌内部的排序算法Ascorer里面的A便是他的名字首字母,他一直坚持寻找简单有效的解决方案,他做事的策略就是先解决80%的问题,再慢慢解决剩下20%的问题,而他之所以总能找到简洁的方法不是靠直觉和运气,而是靠他丰富的研究经验。

    看人物传记,不是去窥见那些八怪,而是看大师学者们对待生活的态度,在工作中运用知识的思维方法,才能在自己身上得到反思。

    新闻分类

    这一章内容也是非常引人入胜的,谁能新闻分类很大程度上依靠的居然是余弦定理的知识。

    人为地对新闻分类主要是按照不同的主题来分类,计算机其实也是如此,只要能把用文字描述的新闻变成可计算的数字,然后设计一个算法来算出两篇新闻的相似性就行了。对一篇新闻中的所有词计算它们的TF-IDF值,得到这篇新闻的一个特征向量,然后就是计算它们的相似性了。

    两个向量的夹角可以用来衡量它们的相近程度,如果两个向量方向一致,说明这两条新闻的用词比例基本一致,主题也就越相近,而要计算向量夹角的大小,就要用到初中所学的余弦定理了,两条新闻向量夹角的余弦接近1时,两条新闻相似,夹角的余弦越小,两条新闻越不相关。

     

    上面提到的用余弦定理来进行新闻分类的方法准确性很好,但只适用于被分类的文本集数量较小的前提下,如果量级很大,那么要对其中的新闻做两两计算还要多次迭代,是很大计算量的,于是也提出了一种用矩阵运算中的奇异值分解来一次性将所有新闻的相关性计算出来的方法。但是这种方法得到的分类结果略显粗糙,在实际应用中,通常先进行奇异值分解,得到粗分类的结果,再利用向量余弦的方法,在粗分类的基础上,进行几次迭代,得到精确的结果。

    还有布尔代数和搜索引擎、图论和爬虫、网页排名技术、网页查询的相关性、地图与本地搜索的动态规划问题、密码学的数学原理、拼音输入法的原理等知识点,难啃但又有趣,非常值得一看。

    全书的核心

    全书其实一直在贯彻一个核心,即真正有用的方法往往简单而又朴实。数学的魅力就是将复杂的问题简单化,正如一个好的算法,应该简单有效、可靠性好且易操作,而不是故弄玄虚,任何复杂的工程问题,最终都可以化繁为简,这应该就是数学之美吧。最后附上我做的这本书的一个思维导图,上传过来有压缩,如不清晰,可加我微信:data_cola,给你发原图。

    《数学之美》思维导图.jpg

    《数学之美》思维导图.jpg


    猜你喜欢:
    如何做好描述统计分析
    如何进行数据图形化?
    简单地聊聊统计学
    什么是好的数据指标:精益数据分析
    泰坦尼克号数据分析

     

    展开全文
  • 虽然已经红了很久,但是“微服务架构”正变得...就是没有一个做到成熟地技术传播出来,同时完美地照顾“初入微服务领域人员”,从 0 开始,采用通俗易懂的语言去讲解微服务架构系列。所以,我们邀请青柳云苏...

    虽然已经红了很久,但是“微服务架构”正变得越来越重要,也将继续火下去。各个公司与技术人员都在分享微服务架构的相关知识与实践经验,但我们发现,目前网上的这些相关文章中,要么上来就是很有借鉴意义的干货,要么就是以高端的专业术语来讲述何为微服务架构。就是没有一个做到成熟地将技术传播出来,同时完美地照顾“初入微服务领域人员”,从 0 开始,采用通俗易懂的语言去讲解微服务架构的系列。所以,我们邀请青柳云的苏槐与 InfoQ 一起共建微服务架构专题“Re:从 0 开始的微服务架构”,为还没有入门该领域的技术人员开路,也帮助微服务架构老手温故知新。

    专题文章传送

    Re:重识微服务架构

    快速快速体验微服务架构?

    微服务架构 API 的开发与治理

    这是专题的第四篇文章,我们来了解一下如何保障微服务架构下的数据一致性。

    随着微服务架构的推广,越来越多的公司采用微服务架构来构建自己的业务平台。就像前边的文章说的,微服务架构为业务开发带来了诸多好处的同时,例如单一职责、独立开发部署、功能复用和系统容错等等,也带来一些问题。

    例如上手难度变大,运维变得更复杂,模块之间的依赖关系更复杂,数据一致性难以保证,等等。但是办法总是比问题多,本篇文章就来介绍一下我们是如何保障微服务架构的数据一致性的。

    微服务架构的数据一致性问题

    以电商平台为例,当用户下单并支付后,系统需要修改订单的状态并且增加用户积分。由于系统采用的是微服务架构,分离出了支付服务、订单服务和积分服务,每个服务都有独立数据库做数据存储。当用户支付成功后,无论是修改订单状态失败还是增加积分失败,都会 造成数据的不一致。

    为了解决例子中的数据一致性问题,一个最直接的办法就是考虑数据的 强一致性。那么如何保证数据的强一致性呢?我们从关系型数据库的 ACID 理论说起。

    ACID

    关系型数据库具有解决复杂事务场景的能力,关系型数据库的事务满足 ACID 的特性。

    • Atomicity:原子性(要么都做,要么都不做)
    • Consistency:一致性(数据库只有一个状态,不存在未确定状态)
    • Isolation:隔离性(事务之间互不干扰)
    • Durability: 永久性(事务一旦提交,数据库记录永久不变)

    具有 ACID 特性的数据库支持数据的强一致性,保证了数据本身不会出现不一致。

    然而微服务架构下,每个微服务都有自己的数据库,导致微服务架构的系统不能简单地满足 ACID,我们就需要寻找微服务架构下的数据一致性解决方案。

    微服务架构的系统本身是一种分布式系统,而本文讨论的问题其实也就是分布式事务之数据一致性的问题,我们来聊聊分布式系统的 CAP 理论和 BASE 理论。

    CAP

    CAP 是指在一个分布式系统下, 包含三个要素:Consistency(一致性)、Availability(可用性)、Partition tolerance(分区容错性),并且 三者不可得兼。

    • C:Consistency,一致性,所有数据变动都是同步的。
    • A:Availability,可用性,即在可以接受的时间范围内正确地响应用户请求。
    • P:Partition tolerance,分区容错性,即某节点或网络分区故障时,系统仍能够提供满足一致性和可用性的服务。

    关系型数据库 单节点 保证了数据强一致性(C)和可用性(A),但是却无法保证分区容错性(P)。

    然而在分布式系统下,为了保证模块的分区容错性(P),只能在数据强一致性(C)和可用性(A)之间做平衡。具体表现为在一定时间内,可能模块之间数据是不一致的,但是通过自动或手动补偿后能够达到最终的一致。

    BASE

    BASE 理论主要是解决 CAP 理论中分布式系统的可用性和一致性不可兼得的问题。BASE 理论包含以下三个要素:

    • BA:Basically Available,基本可用。
    • S:Soft State,软状态,状态可以有一段时间不同步。
    • E:Eventually Consistent,最终一致,最终数据是一致的就可以了,而不是时时保持强一致。

    BASE 模型与 ACID 不同,满足 CAP 理论,通过 牺牲强一致性来保证系统可用性。由于牺牲了强一致性,系统在处理请求的过程中,数据可以存在短时的不一致。

    系统在处理业务时,记录每一步的临时状态。当出现异常时,根据状态判断是否继续处理请求或者退回原始状态,从而达到数据的最终一致。

    例如,在上面的案例中,支付成功,订单也成功,但增加积分失败,此时,不应回滚支付和订单,而应通过一些 补偿方法 来让积分得以正确地增加。后面会讲到具体的实现方法。

    在分享我们的分布式事务实践方案之前,先看看早期解决分布式事务问题的二阶段提交协议。

    二阶段提交协议

    X/Open DTP(Distributed Transaction Process)是一个分布式事务模型,此模型主要使用二阶段提交(2PC,Two-Phase-Commit)来保证分布式事务的完整性。在这个模型里面,有三个角色:

    • AP:Application,应用程序,业务层。
    • RM:Resource Manager,资源管理器,关系型数据库或支持 XA 接口(XA 规范是 X/Open 组织定义的分布式事务规范)的组件。
    • TM: Transaction Manager ,事务管理器,负责各个 RM 的提交和回滚。

    当应用程序(AP)调用了事务管理器(TM)的提交方法时,事务的提交分为两个阶段实行。

     第一阶段(准备阶段)

    TM 通知所有参与事务的各个 RM,给每个 RM 发送 prepare 消息。

    RM 接收到消息后进入准备阶段后,要么直接返回失败,要么创建并执行本地事务,写本地事务日志(redo 和 undo 日志),但是 不提交(此处只保留最后一步耗时最少的提交操作给第二阶段执行)。

     第二阶段(提交 / 回滚阶段)

    TM 收到 RM 准备阶段的失败消息或者获取 RM 返回消息超时,则直接给 RM 发送回滚(rollback)消息,否则发送提交(commit)消息。

    RM 根据 TM 的指令执行提交或者回滚,执行完成后释放所有事务处理过程中使用的锁(最后阶段释放锁)。

     二阶段提交的利弊

    优点

    2PC 提供了一套完整的分布式事务的解决方案,遵循事务严格的 ACID 特性。

    缺点

    • TM 通过 XA 接口与各个 RM 之间进行数据交互,从第一阶段的准备阶段,业务所涉及的数据就被锁定,并且锁定跨越整个提交流程。在高并发和涉及业务模块较多的情况下 对数据库的性能影响较大。
    • 二阶段是 反可伸缩模式 的,业务规模越大,涉及模块越多,局限性越大,系统可伸缩性越差。
    • 在技术栈比较杂的分布式应用中,存储组件有很多 不支持 XA 协议。

    二阶段的诸多弊端,导致分布式系统下无法直接使用此方案来解决数据一致性问题,但它提供了解决分布式系统下数据一致性问题的思路。。

    下面就通过案例来分享我们是如何保证微服务架构的数据一致性的。

    可靠消息最终一致性

    可靠消息最终一致性方案本质上是 利用 MQ 组件实现的二阶段提交。此方案涉及 3 个模块:

    • 上游应用,执行业务并发送 MQ 消息。
    • 可靠消息服务和 MQ 消息组件,协调上下游消息的传递,并确保上下游数据的一致性。
    • 下游应用,监听 MQ 的消息并执行自身业务。

    上游应用执行业务并发送 MQ 消息(第一阶段)

    上游应用将本地业务执行和消息发送绑定在同一个本地事务中,保证要么本地操作成功并发送 MQ 消息,要么两步操作都失败并回滚。

    上游应用和可靠消息之间的业务交互图如下:

    1. 上游应用发送待确认消息到可靠消息系统
    2. 可靠消息系统保存待确认消息并返回
    3. 上游应用执行本地业务
    4. 上游应用通知可靠消息系统确认业务已执行并发送消息。
    5. 可靠消息系统修改消息状态为发送状态并将消息投递到 MQ 中间件。

    以上每一步都可能出现失败情况,分析一下这 5 步出现异常后上游业务和消息发送是否一致:

    上游应用执行完成,下游应用尚未执行或执行失败时,此事务即处于 BASE 理论的 Soft State 状态。

    下游应用监听 MQ 消息并执行业务(第二阶段)

    下游应用监听 MQ 消息并执行业务,并且将消息的消费结果通知可靠消息服务。

    可靠消息的状态需要和下游应用的业务执行保持一致,可靠消息状态不是已完成时,确保下游应用未执行,可靠消息状态是已完成时,确保下游应用已执行。

    下游应用和可靠消息服务之间的交互图如下:

    1. 下游应用监听 MQ 消息组件并获取消息
    2. 下游应用根据 MQ 消息体信息处理本地业务
    3. 下游应用向 MQ 组件自动发送 ACK 确认消息被消费
    4. 下游应用通知可靠消息系统消息被成功消费,可靠消息将该消息状态更改为已完成。

    以上每一步都可能出现失败情况,分析一下这 4 步出现异常后下游业务和消息状态是否一致:

    通过分析以上两个阶段可能失败的情况,为了确保上下游数据的最终一致性,在可靠消息系统中,需要开发 消息状态确认 和 消息重发 两个功能以实现 BASE 理论的 Eventually Consistent 特性。

     消息状态确认

    可靠消息服务定时监听消息的状态,如果存在状态为待确认并且超时的消息,则表示上游应用和可靠消息交互中的步骤 4 或者 5 出现异常。

    可靠消息则携带消息体内的信息向上游应用发起请求查询该业务是否已执行。上游应用提供一个可查询接口供可靠消息追溯业务执行状态,如果业务执行成功则更改消息状态为已发送,否则删除此消息确保数据一致。具体流程如下:

    1. 可靠消息查询超时的待确认状态的消息
    2. 向上游应用查询业务执行的情况
    3. 业务未执行,则删除该消息,保证业务和可靠消息服务的一致性。业务已执行,则修改消息状态为已发送,并发送消息到 MQ 组件。

     消息重发

    消息已发送则表示上游应用已经执行,接下来则确保下游应用也能正常执行。

    可靠消息服务发现可靠消息服务中存在消息状态为已发送并且超时的消息,则表示可靠消息服务和下游应用中存在异常的步骤,无论哪个步骤出现异常,可靠消息服务都将此消息重新投递到 MQ 组件中供下游应用监听。

    下游应用监听到此消息后,在保证幂等性的情况下重新执行业务并通知可靠消息服务此消息已经成功消费,最终确保上游应用、下游应用的数据最终一致性。具体流程如下:

    1. 可靠消息服务定时查询状态为已发送并超时的消息
    2. 可靠消息将消息重新投递到 MQ 组件中
    3. 下游应用监听消息,在满足幂等性的条件下,重新执行业务。
    4. 下游应用通知可靠消息服务该消息已经成功消费。

    通过消息状态确认和消息重发两个功能,可以确保上游应用、可靠消息服务和下游应用数据的最终一致性。

    当然在实际接入过程中,需要引入 人工干预 功能。比如引入重发次数限制,超过重发次数限制的将消息修改为死亡消息,等待人工干预。

    代入开篇案例,通过可靠消息最终一致性方案,第一阶段,订单状态更改之前,订单服务向可靠消息服务请求保存待确认消息。可靠消息服务保存消息并返回。

    订单服务接收到返回信息后执行本地业务并通知可靠消息服务业务已执行。消息服务更改消息状态并将消息投递到 MQ 中间件。

    第二阶段,积分系统监听到 MQ 消息,查看积分是否已增加,如果没有增加则修改积分,然后请求可靠消息服务。可靠消息服务接收到积分系统的请求,将消息状态更改为已完成。

    到这里,已经介绍完如何通过可靠消息服务来保证数据的一致性。但由于引入了可靠消息服务和消息队列,带来了一定的 复杂性,所以,它更 适用于跨平台技术栈不统一的场景。

    下面再来介绍在技术栈统一的情况下,如何通过 TCC 来解决数据一致的方法。

    TCC(Try-Confirm-Cancel)

    TCC 方案是二阶段提交的 另一种实现方式,它涉及 3 个模块,主业务、从业务 和 活动管理器(协作者)。

    下面这张图是互联网上关于 TCC 比较经典的图示:

    第一阶段:主业务服务分别调用所有从业务服务的 try 操作,并在活动管理器中记录所有从业务服务。当所有从业务服务 try 成功或者某个从业务服务 try 失败时,进入第二阶段。

    第二阶段:活动管理器根据第一阶段从业务服务的 try 结果来执行 confirm 或 cancel 操作。如果第一阶段所有从业务服务都 try 成功,则协作者调用所有从业务服务的 confirm 操作,否则,调用所有从业务服务的 cancel 操作。

    在第二阶段中,confirm 和 cancel 同样存在失败情况,所以需要对这两种情况做 异常处理 以保证数据一致性。

    1. Confirm 失败:则回滚所有 confirm 操作并执行 cancel 操作。
    2. Cancel 失败:从业务服务需要提供自动 cancel 机制,以保证 cancel 成功。

    目前有很多基于 RPC 的 TCC 框架,但是不适用于微服务架构下基于 HTTP 协议的交互模式。我们这次只讨论基于 HTTP 协议的 TCC 实现。具体的实现流程如下:

    1. 主业务服务调用从业务服务的 try 操作,并获取 confirm/cancel 接口和超时时间。
    2. 如果从业务都 try 成功,主业务服务执行本地业务,并将获取的 confirm/cancel 接口发送给活动管理器,活动管理器会顺序调用从业务 1 和从业务 2 的 confirm 接口并记录请求状态,如果请求成功,则通知主业务服务提交本地事务。如果 confirm 部分失败,则活动管理器会顺序调用从业务 1 和从业务 2 的 cancel 接口来取消 try 的操作。
    3. 如果从业务部分或全部 try 失败,则主业务直接回滚并结束,而 try 成功的从业务服务则通过定时任务来处理处于 try 完成但超时的数据,将这些数据做回滚处理保证主业务服务和从业务服务的数据一致。

    代入开篇提到的案例,通过 TCC 方案,订单服务在订单状态修改之前执行预增积分操作(try),并从积分服务获取 confirm/cancel 预增积分的请求地址。

    如果预增积分(try)成功,则订单服务更改订单状态并通知活动管理器,活动管理器请求积分模块的 confirm 接口来增加积分。

    如果预增积分(try)失败,则订单服务业务回滚。积分服务通过定时任务删除预增积分(try)超时的数据。

    另外如果活动管理器调用积分服务的 confirm 接口失败,则活动管理器调用积分服务 cancel 接口来取消预增积分,从而,保证订单和积分数据的最终一致性。

    通过上面的对可靠消息服务和 TCC 方案的描述,我们 解决了技术栈一致和不一致的两种情况下的数据一致性问题。

    但是,通常在这些核心业务上有 很多附加业务,比如当用户支付完成后,需要通过短信通知用户支付成功。

    这一类业务的成功或者失败不会影响核心业务,甚至很多大型互联网平台在并高并发的情况下会主动关闭这一类业务以保证核心业务的顺利执行。那么怎么处理这类情况呢,我们来看看最大努力通知方案。

    最大努力通知

    最大努力通知方案涉及三个模块:

    • 上游应用,发消息到 MQ 队列。
    • 下游应用(例如短信服务、邮件服务),接受请求,并返回通知结果。
    • 最大努力通知服务,监听消息队列,将消息存储到数据库中,并按照通知规则调用下游应用的发送通知接口。

    具体流程如下:

    1. 上游应用发送 MQ 消息到 MQ 组件内,消息内包含通知规则和通知地址
    2. 最大努力通知服务监听到 MQ 内的消息,解析通知规则并放入延时队列等待触发通知
    3. 最大努力通知服务调用下游的通知地址,如果调用成功,则该消息标记为通知成功,如果失败则在满足通知规则(例如 5 分钟发一次,共发送 10 次)的情况下重新放入延时队列等待下次触发。

    最大努力通知服务表示在 不影响主业务 的情况下,尽可能地确保数据的一致性。它需要开发人员根据业务来指定通知规则,在满足通知规则的前提下,尽可能的确保数据的一致,以尽到最大努力的目的。

    根据不同的业务可以定制不同的通知规则,比如通知支付结果等相对严谨的业务,可以将通知频率设置高一些,通知时间长一些,比如隔 5 分钟通知一次,持续时间 1 小时。

    如果不重要的业务,比如通知用户积分增加,则可以将通知频率设置低一些,时间短一些,比如 10 分钟通知一次,持续 30 分钟。

    代入上面提到的支付成功短信通知用户的案例,通过最大努力通知方案,当支付成功后,将消息发送到 MQ 中间件,在消息中,定义发送规则为 5 分钟一次,最大发送数为 10 次。

    最大努力通知服务监听 MQ 消息并根据规则调用消息通知服务(短信服务)的消息发送接口,并记录每次调用的日志信息。在通知成功或者已通知 10 次时,停止通知。

    总   结

    上面通过案例详细介绍了我们解决微服务之间数据不一致问题的三种方案,下面通过一张简单的对比图,为大家选择合适的解决方案提供简单依据。


    展开全文
  • 虽然已经红了很久,但是“微服务架构”正变得...就是没有一个做到成熟地技术传播出来,同时完美地照顾“初入微服务领域人员”,从 0 开始,采用通俗易懂的语言去讲解微服务架构系列。所以,我们邀请青柳云

    虽然已经红了很久,但是“微服务架构”正变得越来越重要,也将继续火下去。各个公司与技术人员都在分享微服务架构的相关知识与实践经验,但我们发现,目前网上的这些相关文章中,要么上来就是很有借鉴意义的干货,要么就是以高端的专业术语来讲述何为微服务架构。就是没有一个做到成熟地将技术传播出来,同时完美地照顾“初入微服务领域人员”,从 0 开始,采用通俗易懂的语言去讲解微服务架构的系列。所以,我们邀请青柳云的苏槐与 InfoQ 一起共建微服务架构专题“ Re:从 0 开始的微服务架构”,为还没有入门该领域的技术人员开路,也帮助微服务架构老手温故知新。

    专题文章传送

    随着微服务架构的推广,越来越多的公司采用微服务架构来构建自己的业务平台。就像前边的文章说的,微服务架构为业务开发带来了诸多好处的同时,例如单一职责、独立开发部署、功能复用和系统容错等等,也带来一些问题。

    例如上手难度变大,运维变得更复杂,模块之间的依赖关系更复杂,数据一致性难以保证,等等。但是办法总是比问题多,本篇文章就来介绍一下我们是如何保障微服务架构的数据一致性的。

    微服务架构的数据一致性问题

    以电商平台为例,当用户下单并支付后,系统需要修改订单的状态并且增加用户积分。由于系统采用的是微服务架构,分离出了支付服务、订单服务和积分服务,每个服务都有独立数据库做数据存储。当用户支付成功后,无论是修改订单状态失败还是增加积分失败,都会造成数据的不一致

    为了解决例子中的数据一致性问题,一个最直接的办法就是考虑数据的 强一致性。那么如何保证数据的强一致性呢?我们从关系型数据库的 ACID 理论说起。

    ACID

    关系型数据库具有解决复杂事务场景的能力,关系型数据库的事务满足 ACID 的特性。

    • Atomicity:原子性(要么都做,要么都不做)

    • Consistency:一致性(数据库只有一个状态,不存在未确定状态)

    • Isolation:隔离性(事务之间互不干扰)

    • Durability: 永久性(事务一旦提交,数据库记录永久不变)

    具有 ACID 特性的数据库支持数据的强一致性,保证了数据本身不会出现不一致。

    然而微服务架构下,每个微服务都有自己的数据库,导致微服务架构的系统不能简单地满足 ACID,我们就需要寻找微服务架构下的数据一致性解决方案。

    微服务架构的系统本身是一种分布式系统,而本文讨论的问题其实也就是分布式事务之数据一致性的问题,我们来聊聊分布式系统的 CAP 理论和 BASE 理论。

    CAP

    CAP 是指在一个分布式系统下, 包含三个要素:Consistency(一致性)、Availability(可用性)、Partition tolerance(分区容错性),并且 三者不可得兼

    • C:Consistency,一致性,所有数据变动都是同步的。

    • A:Availability,可用性,即在可以接受的时间范围内正确地响应用户请求。

    • P:Partition tolerance,分区容错性,即某节点或网络分区故障时,系统仍能够提供满足一致性和可用性的服务。

    关系型数据库 单节点保证了数据强一致性(C)和可用性(A),但是却无法保证分区容错性(P)。

    然而在分布式系统下,为了保证模块的分区容错性(P),只能在数据强一致性(C)和可用性(A)之间做平衡。具体表现为在一定时间内,可能模块之间数据是不一致的,但是通过自动或手动补偿后能够达到最终的一致。

    BASE

    BASE 理论主要是解决 CAP 理论中分布式系统的可用性和一致性不可兼得的问题。BASE 理论包含以下三个要素:

    • BA:Basically Available,基本可用。

    • S:Soft State,软状态,状态可以有一段时间不同步。

    • E:Eventually Consistent,最终一致,最终数据是一致的就可以了,而不是时时保持强一致。

    BASE 模型与 ACID 不同,满足 CAP 理论,通过 牺牲强一致性来保证系统可用性。由于牺牲了强一致性,系统在处理请求的过程中,数据可以存在短时的不一致。

    系统在处理业务时,记录每一步的临时状态。当出现异常时,根据状态判断是否继续处理请求或者退回原始状态,从而达到数据的最终一致。

    例如,在上面的案例中,支付成功,订单也成功,但增加积分失败,此时,不应回滚支付和订单,而应通过一些 补偿方法来让积分得以正确地增加。后面会讲到具体的实现方法。

    在分享我们的分布式事务实践方案之前,先看看早期解决分布式事务问题的二阶段提交协议。

    二阶段提交协议

    X/Open DTP(Distributed Transaction Process)是一个分布式事务模型,此模型主要使用二阶段提交(2PC,Two-Phase-Commit)来保证分布式事务的完整性。在这个模型里面,有三个角色:

    • AP:Application,应用程序,业务层。

    • RM:Resource Manager,资源管理器,关系型数据库或支持 XA 接口(XA 规范是 X/Open 组织定义的分布式事务规范)的组件。

    • TM: Transaction Manager,事务管理器,负责各个 RM 的提交和回滚。

    当应用程序(AP)调用了事务管理器(TM)的提交方法时,事务的提交分为两个阶段实行

    第一阶段(准备阶段)

    TM 通知所有参与事务的各个 RM,给每个 RM 发送 prepare 消息。

    RM 接收到消息后进入准备阶段后,要么直接返回失败,要么创建并执行本地事务,写本地事务日志(redo 和 undo 日志),但是 不提交(此处只保留最后一步耗时最少的提交操作给第二阶段执行)。

    第二阶段(提交 / 回滚阶段)

    TM 收到 RM 准备阶段的失败消息或者获取 RM 返回消息超时,则直接给 RM 发送回滚(rollback)消息,否则发送提交(commit)消息。

    RM 根据 TM 的指令执行提交或者回滚,执行完成后释放所有事务处理过程中使用的锁(最后阶段释放锁)。

    二阶段提交的利弊

    优点

    2PC 提供了一套完整的分布式事务的解决方案,遵循事务严格的 ACID 特性。

    缺点

    • TM 通过 XA 接口与各个 RM 之间进行数据交互,从第一阶段的准备阶段,业务所涉及的数据就被锁定,并且锁定跨越整个提交流程。在高并发和涉及业务模块较多的情况下 对数据库的性能影响较大

    • 二阶段是 反可伸缩模式的,业务规模越大,涉及模块越多,局限性越大,系统可伸缩性越差。

    • 在技术栈比较杂的分布式应用中,存储组件有很多 不支持 XA 协议

    二阶段的诸多弊端,导致分布式系统下无法直接使用此方案来解决数据一致性问题,但它提供了解决分布式系统下数据一致性问题的思路。。

    下面就通过案例来分享我们是如何保证微服务架构的数据一致性的。

    可靠消息最终一致性

    可靠消息最终一致性方案本质上是 利用 MQ 组件实现的二阶段提交。此方案涉及 3 个模块:

    • 上游应用,执行业务并发送 MQ 消息。

    • 可靠消息服务和 MQ 消息组件,协调上下游消息的传递,并确保上下游数据的一致性。

    • 下游应用,监听 MQ 的消息并执行自身业务。

    上游应用执行业务并发送 MQ 消息(第一阶段)

    上游应用将本地业务执行和消息发送绑定在同一个本地事务中,保证要么本地操作成功并发送 MQ 消息,要么两步操作都失败并回滚。

    上游应用和可靠消息之间的业务交互图如下:

    1. 上游应用发送待确认消息到可靠消息系统

    2. 可靠消息系统保存待确认消息并返回

    3. 上游应用执行本地业务

    4. 上游应用通知可靠消息系统确认业务已执行并发送消息。

    5. 可靠消息系统修改消息状态为发送状态并将消息投递到 MQ 中间件。

    以上每一步都可能出现失败情况,分析一下这 5 步出现异常后上游业务和消息发送是否一致:

    上游应用执行完成,下游应用尚未执行或执行失败时,此事务即处于 BASE 理论的 Soft State状态。

    下游应用监听 MQ 消息并执行业务(第二阶段)

    下游应用监听 MQ 消息并执行业务,并且将消息的消费结果通知可靠消息服务。

    可靠消息的状态需要和下游应用的业务执行保持一致,可靠消息状态不是已完成时,确保下游应用未执行,可靠消息状态是已完成时,确保下游应用已执行。

    下游应用和可靠消息服务之间的交互图如下:

    1. 下游应用监听 MQ 消息组件并获取消息

    2. 下游应用根据 MQ 消息体信息处理本地业务

    3. 下游应用向 MQ 组件自动发送 ACK 确认消息被消费

    4. 下游应用通知可靠消息系统消息被成功消费,可靠消息将该消息状态更改为已完成。

    以上每一步都可能出现失败情况,分析一下这 4 步出现异常后下游业务和消息状态是否一致:

    通过分析以上两个阶段可能失败的情况,为了确保上下游数据的最终一致性,在可靠消息系统中,需要开发 消息状态确认和 消息重发两个功能以实现 BASE 理论的 Eventually Consistent特性。

    消息状态确认

    可靠消息服务定时监听消息的状态,如果存在状态为待确认并且超时的消息,则表示上游应用和可靠消息交互中的步骤 4 或者 5 出现异常。

    可靠消息则携带消息体内的信息向上游应用发起请求查询该业务是否已执行。上游应用提供一个可查询接口供可靠消息追溯业务执行状态,如果业务执行成功则更改消息状态为已发送,否则删除此消息确保数据一致。具体流程如下:

    1. 可靠消息查询超时的待确认状态的消息

    2. 向上游应用查询业务执行的情况

    3. 业务未执行,则删除该消息,保证业务和可靠消息服务的一致性。业务已执行,则修改消息状态为已发送,并发送消息到 MQ 组件。

    消息重发

    消息已发送则表示上游应用已经执行,接下来则确保下游应用也能正常执行。

    可靠消息服务发现可靠消息服务中存在消息状态为已发送并且超时的消息,则表示可靠消息服务和下游应用中存在异常的步骤,无论哪个步骤出现异常,可靠消息服务都将此消息重新投递到 MQ 组件中供下游应用监听。

    下游应用监听到此消息后,在保证幂等性的情况下重新执行业务并通知可靠消息服务此消息已经成功消费,最终确保上游应用、下游应用的数据最终一致性。具体流程如下:

    1. 可靠消息服务定时查询状态为已发送并超时的消息

    2. 可靠消息将消息重新投递到 MQ 组件中

    3. 下游应用监听消息,在满足幂等性的条件下,重新执行业务。

    4. 下游应用通知可靠消息服务该消息已经成功消费。

    通过消息状态确认和消息重发两个功能,可以确保上游应用、可靠消息服务和下游应用数据的最终一致性。

    当然在实际接入过程中,需要引入 人工干预功能。比如引入重发次数限制,超过重发次数限制的将消息修改为死亡消息,等待人工干预。

    代入开篇案例,通过可靠消息最终一致性方案,第一阶段,订单状态更改之前,订单服务向可靠消息服务请求保存待确认消息。可靠消息服务保存消息并返回。

    订单服务接收到返回信息后执行本地业务并通知可靠消息服务业务已执行。消息服务更改消息状态并将消息投递到 MQ 中间件。

    第二阶段,积分系统监听到 MQ 消息,查看积分是否已增加,如果没有增加则修改积分,然后请求可靠消息服务。可靠消息服务接收到积分系统的请求,将消息状态更改为已完成。

    到这里,已经介绍完如何通过可靠消息服务来保证数据的一致性。但由于引入了可靠消息服务和消息队列,带来了一定的 复杂性,所以,它更 适用于跨平台技术栈不统一的场景

    下面再来介绍在技术栈统一的情况下,如何通过 TCC 来解决数据一致的方法。

    TCC(Try-Confirm-Cancel)

    TCC 方案是二阶段提交的 另一种实现方式,它涉及 3 个模块,主业务从业务和 活动管理器(协作者)

    下面这张图是互联网上关于 TCC 比较经典的图示:

    第一阶段:主业务服务分别调用所有从业务服务的 try 操作,并在活动管理器中记录所有从业务服务。当所有从业务服务 try 成功或者某个从业务服务 try 失败时,进入第二阶段。

    第二阶段:活动管理器根据第一阶段从业务服务的 try 结果来执行 confirm 或 cancel 操作。如果第一阶段所有从业务服务都 try 成功,则协作者调用所有从业务服务的 confirm 操作,否则,调用所有从业务服务的 cancel 操作。

    在第二阶段中,confirm 和 cancel 同样存在失败情况,所以需要对这两种情况做 异常处理以保证数据一致性。

    1. Confirm 失败:则回滚所有 confirm 操作并执行 cancel 操作。

    2. Cancel 失败:从业务服务需要提供自动 cancel 机制,以保证 cancel 成功。

    目前有很多基于 RPC 的 TCC 框架,但是不适用于微服务架构下基于 HTTP 协议的交互模式。我们这次只讨论基于 HTTP 协议的 TCC 实现。具体的实现流程如下:

    1. 主业务服务调用从业务服务的 try 操作,并获取 confirm/cancel 接口和超时时间。

    2. 如果从业务都 try 成功,主业务服务执行本地业务,并将获取的 confirm/cancel 接口发送给活动管理器,活动管理器会顺序调用从业务 1 和从业务 2 的 confirm 接口并记录请求状态,如果请求成功,则通知主业务服务提交本地事务。如果 confirm 部分失败,则活动管理器会顺序调用从业务 1 和从业务 2 的 cancel 接口来取消 try 的操作。

    3. 如果从业务部分或全部 try 失败,则主业务直接回滚并结束,而 try 成功的从业务服务则通过定时任务来处理处于 try 完成但超时的数据,将这些数据做回滚处理保证主业务服务和从业务服务的数据一致。

    代入开篇提到的案例,通过 TCC 方案,订单服务在订单状态修改之前执行预增积分操作(try),并从积分服务获取 confirm/cancel 预增积分的请求地址。

    如果预增积分(try)成功,则订单服务更改订单状态并通知活动管理器,活动管理器请求积分模块的 confirm 接口来增加积分。

    如果预增积分(try)失败,则订单服务业务回滚。积分服务通过定时任务删除预增积分(try)超时的数据。

    另外如果活动管理器调用积分服务的 confirm 接口失败,则活动管理器调用积分服务 cancel 接口来取消预增积分,从而,保证订单和积分数据的最终一致性。

    通过上面的对可靠消息服务和 TCC 方案的描述,我们 解决了技术栈一致和不一致的两种情况下的数据一致性问题

    但是,通常在这些核心业务上有 很多附加业务,比如当用户支付完成后,需要通过短信通知用户支付成功。

    这一类业务的成功或者失败不会影响核心业务,甚至很多大型互联网平台在并高并发的情况下会主动关闭这一类业务以保证核心业务的顺利执行。那么怎么处理这类情况呢,我们来看看最大努力通知方案。

    最大努力通知

    最大努力通知方案涉及三个模块:

    • 上游应用,发消息到 MQ 队列。

    • 下游应用(例如短信服务、邮件服务),接受请求,并返回通知结果。

    • 最大努力通知服务,监听消息队列,将消息存储到数据库中,并按照通知规则调用下游应用的发送通知接口。

    具体流程如下:

    1. 上游应用发送 MQ 消息到 MQ 组件内,消息内包含通知规则和通知地址

    2. 最大努力通知服务监听到 MQ 内的消息,解析通知规则并放入延时队列等待触发通知

    3. 最大努力通知服务调用下游的通知地址,如果调用成功,则该消息标记为通知成功,如果失败则在满足通知规则(例如 5 分钟发一次,共发送 10 次)的情况下重新放入延时队列等待下次触发。

    最大努力通知服务表示在 不影响主业务的情况下,尽可能地确保数据的一致性。它需要开发人员根据业务来指定通知规则,在满足通知规则的前提下,尽可能的确保数据的一致,以尽到最大努力的目的。

    根据不同的业务可以定制不同的通知规则,比如通知支付结果等相对严谨的业务,可以将通知频率设置高一些,通知时间长一些,比如隔 5 分钟通知一次,持续时间 1 小时。

    如果不重要的业务,比如通知用户积分增加,则可以将通知频率设置低一些,时间短一些,比如 10 分钟通知一次,持续 30 分钟。

    代入上面提到的支付成功短信通知用户的案例,通过最大努力通知方案,当支付成功后,将消息发送到 MQ 中间件,在消息中,定义发送规则为 5 分钟一次,最大发送数为 10 次。

    最大努力通知服务监听 MQ 消息并根据规则调用消息通知服务(短信服务)的消息发送接口,并记录每次调用的日志信息。在通知成功或者已通知 10 次时,停止通知。

    总 结

    上面通过案例详细介绍了我们解决微服务之间数据不一致问题的三种方案,下面通过一张简单的对比图,为大家选择合适的解决方案提供简单依据。

    作者介绍

    小羊,青柳云架构师,现服务于神州数码青柳云团队,擅长大型项目规划、微服务架构和分布式架构。

    苏槐,微信号 Sulaohuai,青柳云研发总监,现服务于神州数码青柳云团队,曾就职于 Oracle,新加坡电信等企业。擅长容器技术、微服务架构、敏捷开发及技术管理。

    下篇文章

    下篇文章介绍如何 使用 Docker来支撑微服务架构部署及运维。

    沈剑聊微服务:先做好你的服务拆分

    更多精彩技术专题请关注QCon 全球软件开发大会,QCon 上海站 9 折优惠报名最后一周,2017 年 09 月 17 日前,立减 680 元,团购报名更多优惠~点击【阅读原文】跟技术大咖零距离。欲购票或咨询问题可联系购票经理 Hanna ,电话:15110019061,微信:qcon-0410。

    展开全文
  • 有关进程通信的知识主要... 本文按照这五个部分提出顺序进行讲解,力求通俗易懂、融会贯通。  ①什么是进程通信?  需要首先明确是,进程通信并不是指进程间“传递数据”。  为了说明进程通信,需要...

      有关进程通信的知识主要分为五个部分:

      ①什么是进程通信;

      ②实现进程通信的误区;

      ③如何正确实现进程通信;

      ④经典的进程通信问题与信号量机制;

      ⑤避免编程失误的“管程”。

      本文将按照这五个部分的提出顺序进行讲解,力求通俗易懂、融会贯通。

     

      ①什么是进程通信?

      需要首先明确的是,进程通信并不是指进程间“传递数据”。

      为了说明进程通信,需要先介绍一下进程通信的背景。现代操作系统中的进程间可能存在着共享的内存区,比如字处理进程A(可以想象为Word)、字处理进程B(可以想象为记事本)和打印机进程C共享一小块内存:待打印文件地址队列。该队列中有一个指针out指向队列中下一个被打印的文件地址,还有一个指针in指向队列尾的后一位置,即新的待打印文件地址应存入的位置。显然,指针out是供进程C访问的,每当打印机空闲且out!=in,进程C就打印out所指的文件。而指针in则是供进程A与进程B访问的,每当它们有希望打印的文件时就执行如下三步:“读取in”、“向in所指位置写入待打印文件地址”、“修改in使其指向下一位置”。

      但是A和B都能读写指针in就会带来冲突问题:假设现在A占用着CPU并准备打印文件,A读取了in并将待打印文件名写入了in所指位置,但是A还没来得及修改in,CPU就切换到了进程B执行,B在执行过程中也准备打印文件,并且完成了对in的所有操作。一段时间后,CPU又切换到了进程A,但此时的进程A并不知道自己写入到队列的文件名已经被B给覆盖了,A只会继续执行“修改in使其指向下一位置”的操作,从而出现了进程A与进程B的“冲突”。

      这种存在共享内存区的进程间的冲突问题,解决方法的思路是统一的:当某个进程正在操作共享内存区时,其他进程不得操作共享内存区。这个思路实现的关键点就是:令其他进程知道“有一个进程在操作共享内存区”,因此这类问题就被称为进程通信问题,通信的“内容”就是:有没有其他进程在操作共享内存区。(讲解到信号量机制时进程通信将广义化,但依然不是进程间的“实际通信”,而是某些信号的共享)

      因为“操作共享内存区”太长,所以人们一般称正在操作共享内存区的进程是在临界区内,同时将进程中需要操作共享内存区的部分代码称之为临界区代码。思路也就可以称作:当有进程在临界区时,其他进程不得进入临界区。

     

      ②实现进程通信的误区

      因为实现进程通信的关键,就是令其他进程知道现在已经有进程在临界区了,所以一个很简单的解决思路就出来了:

      将临界区想象成一个房子,同一时间内房子内只能有一个进程,那么确保这一点的方法就是给房子加锁(mutex),如果锁是锁上的,则准备进入的进程不得进入,如果锁是打开的,则准备进入的进程可以进入,并且进入后要将锁锁上,此外退出时也要负责将锁打开。

      将上述想法转换为代码表示,就是令每个进程在临界区代码的前后,分别添加如下代码,其中mutex为共享内存区中的一个变量:

     1 int mutex=1; //mutex为1表示锁打开,为0表示锁关闭
     2 while(true)
     3 {
     4     //执行非临界区代码
     5     
     6     //准备执行临界区代码,即准备进入临界区
     7 
     8     while(mutex==0);//如果mutex为0,说明有其他进程在临界区内,当前进程应卡在此处
     9     mutex=0;//若代码能执行至此处,说明mutex为假,即没有其他进程在临界区,于是将mutex设为真,告知其他进程当前有(本)进程在临界区
    10 
    11     /*临界区代码*/
    12 
    13     //准备退出临界区,解开锁
    14     mutex=1;
    15 
    16     //执行非临界区代码
    17 }

      但是上述代码是无法解决进程通信问题的!原因就是:如果没有计算机底层(硬件或操作系统)的限制,那么进程间的切换可能发生在任意两条机器指令之间,更遑论高级程序语言的两条语句之间。

      现在假设三个进程A、B、C共享某内存区,A已进入临界区,于是欲进入临界区的B在第8行代码处卡住(想进入房子,一直循环判断“锁”的状态)。突然,A退出了临界区,并且CPU切换到了B,于是B结束了第8行的循环(进入了房子),准备执行第9行代码(上锁),但是B还没来得及“上锁”,CPU又因为某特殊原因如中断,被切换到了进程C,并且进程C也想进入临界区,由于此时“锁”是打开的,于是C直接结束了第8行的循环(直接进入房子),准备执行第9行代码。显然,此时临界区内有两个进程了。

      因此,实现进程通信时需要注意的最大误区就是:如果代码中的语句(指令)不是特殊的,那么任意两条语句(指令)间均有被“打断”的可能性。

     

      ③如何正确实现进程通信

      我们先看看一种不需要借助计算机底层支持的解决进程通信的方法:严格轮换法。然后再说说现代计算机实现进程通信的基本技术。

      所谓严格轮换法,依然可以抽象地将临界区看成一个房子,但是这次我们不是靠锁来实现房子内只有一个进程,而是靠“钥匙”,钥匙在哪个进程手上,哪个进程就可以进入临界区,当该进程退出临界区时,需要将钥匙交给下一个进程(不管它要不要进入临界区,反正“轮到你了”)。

      以三个进程0,1,2共享内存区为例,则三个进程的临界区代码分别如下,假设key初始化为0:

    int key=0;
    //
    进程0 while(true) { //不断判断钥匙是否在自己手上,如果不在则一直循环等下去 while(key!=0); //执行临界区代码 //退出临界区,将钥匙交给下一个进程 key=1; } //进程1 while(true) { //不断判断钥匙是否在自己手上,如果不在则一直循环等下去 while(key!=1); //执行临界区代码 //退出临界区,将钥匙交给下一个进程 key=2; } //进程2 while(true) { //不断判断钥匙是否在自己手上,如果不在则一直循环等下去 while(key!=2); //执行临界区代码 //退出临界区,将钥匙交给下一个进程 key=0; }

      严格轮换法的确可以解决进程通信,但是其效率非常低,原因如下:

      1.严格轮换:如果此时key为2,那么只有进程2可以进入临界区,哪怕进程2从始至终都没有进入临界区,进程0和进程1也不得进入临界区。

      2.忙等待:如果此时进程0占用着CPU而key为1或2,那么进程0不能进入临界区,只能一直循环判断key的值,从整个系统的角度来说,CPU在“忙”,但进程却在“等待”,也就是说CPU此时一直在空转(就像汽车空挡猛踩油门)

     

      为了解决忙等待和严格轮换的缺陷,一种新的思路被提了出来,我称之为沉睡与唤醒策略:令进程的共享内存区中保存一个特殊的“沉睡进程队列”,如果进程A在准备进入临界区时发现已有其他进程在临界区内,则进程A将自己的信息加入到沉睡进程队列,即“沉睡”,然后自我阻塞,从而让出CPU、避免忙等待,当临界区内的进程退出临界区时,需要负责检查沉睡进程队列,若队列不为空,则需要将其中的某个进程移出队列,并将其“唤醒”,使其从阻塞态进入就绪态。当然,根据实现方式的不同,沉睡、阻塞可能是一个操作,移出队列和唤醒也是一个操作。

    int mutex=1;//mutex为1表示锁打开,为0表示锁关闭
    //
    沉睡 void sleep(int process) { //将process所表示的进程加入到沉睡进程队列 //将process由运行态转换为阻塞态 } //唤醒 void wakeup() { //检查沉睡进程队列,若不为空,则移出某进程,并将其由阻塞态转换为就绪态 } //进程中的代码 while(true) { //准备进入临界区 if(mutex==0) sleep();

    //上锁
    mutex=0;
    //临界区代码 //退出临界区,开锁,唤醒沉睡进程
    mutex=1; wakeup();
    }

      显然,上述代码又踩了②中所提到的误区。

      假设进程A在临界区内,现在进程B占用CPU并且想进入临界区,B检查mutex发现为0,于是准备执行sleep(),但是此时CPU被进程A抢去了,进程A在自己的时间片段内退出了临界区,试着去“唤醒”沉睡的进程,但是却没有沉睡的进程,于是唤醒操作不了了之,接着进程A任务完成、结束。于是CPU又切换到了B,B继续执行sleep(),进入沉睡并阻塞,但是再也没有进程会来叫醒B了……

      上述现象可以这么说:B本来应该由A来唤醒,但偏偏A的唤醒没有被B收到,因为B后来才沉睡。该现象出现的根本原因就是:B决定沉睡和执行沉睡是两个操作,这两个操作之间可能被“打断”。

      再假设,进程B沉睡,进程A在临界区内且占用CPU,进程A在时间片段内执行完了临界区代码,解开了锁,准备执行wakeup(),但是CPU此时被进程C抢走,因为锁已解开,所以C进入了临界区,一段时间后,C尚未退出临界区,但CPU再次切换至进程A,A继续执行wakeup(),将B叫醒,于是B上锁(其实锁已经上了),进入临界区,但是此刻C已在临界区内!

      这个现象可以这么说:A本来打算解开锁、叫醒B,但A解开锁后,C乘虚而入了。这个现象出现的根本原因就是:A解开锁、叫醒沉睡进程是两个操作,这两个操作之间可能被“打断”。此外,即使A先叫醒了B,B也不会再去上锁,因为对于B来说被叫醒即sleep()结束,可以直接开始临界区操作,因此其他进程还是会因为锁打开而进入临界区!

      要想解决上述两个问题,最直接的想法就是令“判断是否沉睡”和“沉睡”合为“一个操作”,“解开锁”和“叫醒沉睡进程”也合为“一个操作”,从而避免被打断,假设下面的sleep()、wakeup()为“原子操作”,即执行时不会被中断,则沉睡与唤醒策略可以实现:

    //沉睡,假设为原子操作
    void sleep(int mutex)
    {
        //检查锁mutex,若为锁上,则沉睡并阻塞调用者,否则上锁并返回
    }
    
    //唤醒,假设为原子操作
    void wakeup(int mutex)
    {
        //检查沉睡进程队列,若队列不为空,则唤醒其中某进程(不开锁,因为被唤醒的进程不会再去上锁),若队列空,则解开锁
    }
    
    //进程代码
    while(true)
    {
        //准备进入临界区
        sleep(mutex);
    
        /*临界区代码*/
    
        //退出临界区,并唤醒存在的沉睡进程
        wakeup(mutex);
    }

      在只有一个CPU的系统中,令sleep()、wakeup()成为原子操作并不复杂,只要将sleep()设为一个系统调用,并且操作系统在执行时(仅仅几条指令的时间而已)暂时屏蔽中断,从而避免执行sleep()、wakeup()时出现进程切换即可,多CPU系统中令sleep()成为原子操作需要一些更特殊的指令或技术。

     

     

     

      ④经典的进程通信问题与信号量机制

      首先看看“生产者-消费者”问题,该问题将引出沉睡与唤醒策略的升级版——信号量机制。

      生产者-消费者问题的背景如下:有两个或多个进程分别为producer和consumer,它们共享的内存区最多可以存放N个产品,producer负责生产产品,consumer负责消费产品,如果共享内存区中已有N个产品,则producer需要沉睡,如果共享内存区中没有产品,则consumer需要沉睡,并且producer和consumer不能同时访问共享内存区。

      一个简单的想法是这样的:

    int count=0; //表示共享内存区中的产品个数
    int mutex=1; //进入临界区的锁,临界区内的进程可以操作共享内存区

    //num即生产者编号,允许存在多个生产者进程
    void
    producer(int num) {
    produce();
    if(count==N) //沉睡 //欲操作共享区,即欲进入临界区 sleep(mutex); put_product(); count++; if(count==1) //count为1说明之前count为0,consumer可能已沉睡,所以唤醒consumer //离开临界区 wakeup(mutex); }
    //num即消费者编号,允许存在多个消费者
    void consumer(int num) { if(count==0) //沉睡 //欲操作共享区,即欲进入临界区 sleep(mutex); take_product(); count--; if(count==N-1) //说明之前count为N,producer可能已沉睡,所以唤醒producer //离开临界区 wakeup(mutex);

    consume(); }

     

      很显然,上述代码又踩了误区,对count的判断和对应的操作之间可能被打断,从而可能错过另一方的唤醒:假设只有一个生产者和一个消费者,生产者发现count为N,然后CPU就被消费者占用,消费者取走一个产品并发现需要唤醒沉睡的生产者,但是此刻生产者没有沉睡,所以唤醒操作不了了之,接着消费者退出临界区,CPU被生产者占用,生产者执行因count已满而导致的沉睡,但是消费者不再有机会叫醒它……

      不幸的是,sleep()和wakeup()并不能解决对count的判断,因为count并不是一把“锁”(锁只有两个状态,count不是),count是一种“信号量”,进程们通过count这个信号量的值来判断自己是否需要沉睡,与进入临界区而沉睡不同,这种沉睡是受条件所限而沉睡。但是解决这个问题只需要将sleep()和wakeup()稍加修改就可以:

    //down即新的sleep,也是一个“原子操作”,semaphore表示信号量,对应sleep()中的锁
    void down(int semaphore)
    {
        //检查semaphore,若大于0则使其减一并返回,若为0则沉睡调用者
    }
    
    //up即新的wakeup,也是一个“原子操作”,semaphore表示信号量,对应wakeup()中的锁
    void up(int semaphore)
    {
        //检查semaphore,若为0则唤醒沉睡进程队列中的某沉睡进程,否则令semaphore+1
    }

     

      与sleep()和wakeup()的参数为锁不同,信号量机制的参数为“信号量”,从而可以解决生产者-消费者问题,同时也可以替代sleep()和wakeup(),因为锁也可以当做是一个信号量:

    //对共享内存区稍作修改,新增变量empty表示空位置个数,count依然表示产品个数
    int count=0;
    int empty=N;
    int mutex=1; //进入临界区的锁,1开0闭,临界区内的进程允许操作共享内存区

    //num表示生产者编号,允许存在多个生产者 void producer(int num) { produce(); down(empty);//减少一个空位置,若已为0则沉睡 //进入临界区 down(mutex); put_product(); //离开临界区 up(mutex); up(count);//若产品数原先为0,则唤醒因为没有产品而沉睡的消费者进程,否则令产品数+1 }
    //num表示消费者编号,允许存在多个消费者
    void consumer(int num) { down(count);//减少一个产品数,若已为0则沉睡 //进入临界区 down(mutex); take_product(); //离开临界区 up(mutex); up(empty);//若空位置原先为0,则唤醒因为没有空位置而沉睡的生产者进程,否则令空位置+1 consume(); }

      

      信号量机制的理解并不困难,个人估计唯一的困惑点就是为什么当semaphore为0时,up()不需要令semaphore+1,这一点的解释用一句话来说就是:因为当semaphore为0时,down()没有令semaphore-1。也可以类比wakeup(),wakeup()在有沉睡进程时是不打开锁的,因为被唤醒的进程不会再去上锁。

      生产者-消费者问题,以及信号量机制带来了一个新的思考:进程间可能不仅仅存在共享内存区的读写冲突问题,还可能存在“资源”共享的问题。在生-消背景下,空位置就是生产者需要的资源,而产品就是消费者需要的资源,进程得在有了需要的资源后才能做自己要做的事。本文最初提到的字处理-打印问题就是生-消问题,只是我们忽略了打印机进程在发现out==in时的操作,如果打印机进程在out==in时选择沉睡,那么字处理进程就得负责将其唤醒。

     

     

      接下来看看哲学家就餐问题,该问题可以进一步地体现进程间资源抢占可能导致的问题。假设有5个哲学家围着桌子坐,每个人面前都有足够的食物,但筷子只有5根,见图:

      

      每个哲学家只会做两件事:思考、吃饭。而每当需要吃饭时,哲学家必须取两根筷子才能吃,并且只能取自己左手和右手的筷子,如上图哲学家A只能用筷子0和筷子1吃饭。

      显然,各个哲学家就相当于各个进程,筷子就是它们需要共享的资源(并且共享方式是连锁的)。先来看看错误的代码:

    int chopMutex[5]={1}; //5根筷子各自的信号量(锁),1可取0不可取

    //
    哲学家进程,num表示哲学家编号,从0到4对应从A到E void philosopher(int num) { while(true) { think(); //思考 down(chopMutex[num]); //拿左边的筷子,若已被左边的哲学家取走,则阻塞 down(chopMutex[(num+1)%5]); //拿右边的筷子,若已被右边的哲学家取走,则阻塞 eat(); //吃饭 //逐个放下筷子 up(chopMutex[num]); up(chopMutex[(num+1)%5]); } }

      上述代码初看仿佛没有问题,每个哲学家都利用信号量机制(此处的信号量即单根筷子的锁)来取筷子。但其实上述代码是有问题的:若A拿起了左边的筷子后就切换到了B,B也拿起了左边的筷子,然后又切换到了C,C也拿起了左边的筷子……最后,每个哲学家都拿到了自己左手边的筷子,但是每个哲学家都会因为拿不到右边的筷子而一直阻塞下去。这种现象我们称之为“死锁”:一个进程的集合中,每一个进程都在等待只能由同一集合中的其他进程才能触发的事件(比如释放某资源)。

      解决哲学家问题的简单解法是:令同一时间只允许一个哲学家吃饭。

    int qualification=1;  //代表吃饭的权利
    //
    哲学家进程,num表示哲学家编号,从0到4对应从A到E void philosopher(int num) { while(true) { think(); //思考 down(qualification); //试图获取吃饭的权利 //拿筷子,吃饭 takeChopsticks(num); takeChopsticks((num+1)%5); eat(); //放下筷子,停止吃饭 putChopsticks(num); putChopsticks((num+1)%5); up(qualification); //交出吃饭权利,即吃完了 } }

     

      上述解法没有问题,只是有缺陷:5根筷子明明可以支持两个哲学家吃饭,比如A和C或者A和D一起吃,上述解法却只让一个人吃。

      可以解决该缺陷的一种解法是,设置一个mutex作为进入临界区的锁,再令每个哲学家对应两个信号量,state和qualification,state表示该哲学家的“状态”:思考、想吃饭、在吃饭;qualification表示该哲学家的“资格”:现在有没有资格吃饭。每个哲学家都可以读、写任一哲学家的状态和资格,因此临界区即读写哲学家状态、资格的代码。

      当哲学家X准备吃饭即想拿筷子时,先进入临界区(从而可以读写任一哲学家的state和qualification),然后将自己的状态改为想吃饭,接着检查自己左右两边的哲学家是否在吃饭,如果均不在吃饭,则自己有资格吃饭,于是通过up()使自己的qualification+1,然后退出临界区,再通过down()使用掉自己的资格;如果左右两边有哲学家在吃饭,则不使自己的qualification+1,退出临界区,再通过down()使用自己的资格,但是因为没有资格,X将阻塞于此,直到正在吃饭的旁边哲学家吃完饭,然后给予自己资格。

      当哲学家Y吃完饭即放下筷子时,先进入临界区,将自己的状态改为思考,接着检查自己左右两边的哲学家是否想吃饭且有资格吃饭,若是则令其qualification+1从而使其得以吃饭,左右哲学家均处理完毕后Y退出临界区。

    #define THINKING 0                 //在思考
    #define HUNGRY 1                   //想吃饭
    #define EATING 2                   //在吃饭
    int state[5]={THINKING};           //表示各个哲学家的状态
    int mutex=1;                       //临界区的锁,为0表示锁上
    int qualification[5]={0};          //哲学家的资格,为1时表示可以吃饭,0表示不可以
    
    //检查哲学家i是否想吃饭且有资格吃饭 void check(int i) { //若哲学家i想吃饭,且其左右哲学家均不在吃饭,则i的吃饭资格+1,并且将i的状态改为正在吃饭 if(state[i]==HUNGRY && state[(i+4)%5]!=EATING && state[(i+1)%5]!=EATING) {
        state[i]=EATING;
       up(qualification[i]);   }
    }
    void takeChopsticks(int i) { down(mutex); //进入临界区(临界区内可读、写哲学家的状态和资格) state[i]=HUNGRY; //表明i想吃饭 check(i); //检查自己是否有资格吃饭 up(mutex); //离开临界区 down(qualification[i]); //若check(i)时确认自己有资格吃饭,则此处用去吃饭资格,否则阻塞直至被给予吃饭资格 } void putChopsticks(int i) { down(mutex); //进入临界区(临界区内可读、写哲学家的状态和资格) state[i]=THINKING; //表明自己不在吃饭也不想吃饭 check((i+4)%5); //检查左边的哲学家是否想且有资格吃饭,若是则给予他资格 check((i+1)%5); //检查右边的哲学家是否想且有资格吃饭,若是则给予他资格 up(mutex); //离开临界区 } void philosopher(int i) { while(true) { think(); takeChopsticks(i); putChopsticks(i); } }

       哲学家进餐问题比生产者-消费者问题要更复杂,因为进程需要的资源不是一种而是两种,而且这两种资源的竞争对象不一样。解决这类问题的关键点就是:如果进程X需要x个资源,则X要么一次性占用这x个资源,要么一个都不占用直到可以一次性占用着x个资源,不能出现占用一部分资源然后等待的情况。

     

     

       最后提出的问题是最复杂的,叫读者-写者问题,在哲学家进餐问题中,资源是“独享”式的:一根筷子如果被一个哲学家取走了,则这根筷子只能属于该哲学家,除非他放下筷子。但是在读者-写者问题中,资源是既“独享”又“共享”的,我们先看看其背景:

      假设存在大量进程共享一个数据库(或文件),为了简化问题,我们再假设进程要么是只会读取数据库的“读者”,要么是只会写入数据库的“写者”,同一时间数据库要么有一个写者在写、要么有不限量个读者在读、要么没有进程访问。

      根据上述要求,该数据库作为一种资源,在读者与写者之间、写者与写者之间是“独享”的:有读者在数据库则写者得等,有写者在数据库则读者、其他写者得等。但是在读者与读者之间又是“共享”的:有读者在数据库则其他后到的读者可以进去读。

      如果不允许读者-读者共享,那么问题就变得很简单,只要给数据库上一把“锁”即可,有进程在数据库,其他想进去的进程就得沉睡。所以读者-写者问题的关键难点就是:如何令已有读者在读的情况下,后来的读者可以进去?

      根据关键难点的描述,一种被称为“读者优先”的解决思路被提出来:

      设置共享变量rd_count,表示当前数据库中读者的数量,设置一把锁rdc_mutex,进程要想操作rd_count,必须利用锁rdc_mutex进出“rd_count的临界区”。再设置一把锁db_mutex,表示数据库的锁。

      写者想进入数据库,必须在数据库内无人的情况下才行,即db_mutex解开时才可以进入数据库。同理,写者退出数据库时,必须解开db_mutex锁。

      若读者想进入数据库,则必须满足两个条件其中一个:

      1.数据库内无人且锁打开  2.数据库内有人但是是读者。

      同理,读者退出数据库时,若数据库内还有人(读者)则直接推出,否则退出并解开db_mutex。我们可以借助rd_count来实现对第二点的判断,详情见代码:

    //读者优先解法
    int db_mutex=1;   //数据库的锁,db即database,1开0闭
    int rd_count=0;     //表示数据库中读者的数量,rd即reader
    int rdc_mutex=1;  //rd_count的锁,rdc即reader_count,1开0闭
    
    //读者进程,num即读者编号
    void reader(int num)
    {
        while(true)
        {    
            /***准备进入数据库***/
            //先通过rdc_mutex进入rd_count临界区
            down(rdc_mutex);
    
            //判断数据库内是否已有读者,若是,则负责抢数据库的锁
            if(rd_count==0)   
                down(db_mutex);
            //若自己是“第一个”读者,则执行至此时已抢到数据库并上了锁,所以令rd_count++
            //若自己不是“第一个”读者,则直接令rd_count++
            rd_count++;  
            up(rdc_mutex);    //离开rd_count临界区
    
            read_data();   //读取数据库数据
    
            /***准备离开数据库***/
            //通过rdc_mutex进入rd_count临界区
            down(rdc_mutex);
            //令rd_count--后判断数据库内是否已无读者,若是则解开数据库的锁,唤醒沉睡进程(若有,必为写者)
            rd_count--;
            if(rd_count==0)
                up(db_mutex);
            up(rdc_mutex);    //离开rd_count临界区
    
            use_data();
        }
    }
    
    //写者进程,num即编号
    void writer(int num)
    {
        while(true)
        {
            produce_data();
    
            //欲进入数据库,检查锁,若锁上沉睡,否则锁上并进入
            down(db_mutex);
            write_data();
            up(db_mutex);   //离开数据库,唤醒沉睡进程(可能是读者也可能是写者)
        }
    }/

     

      显然,上述代码可以满足读者-写者问题的问题,只是存在一点“缺陷”:如果不断地有读者到来,以致于数据库内总是至少有一个读者,那么写者将永远没有机会进入数据库。这也是该解法被称为“读者优先”的原因。因为只要数据库内有读者,那么后面来的读者就可以进入数据库,而不需要在意是否有先到的写者想进入数据库。

      但是在某些情况下,我们希望算法能满足:即使数据库内有读者,如果有写者先到达(在等待),那么后到达的读者也不能进入数据库,必须让先到达的、等待中的写者使用完数据库后才可以进入数据库。

      要想满足该要求,被称为“读写平等”的解决思路被提了出来:在数据库“门前”设立一个“候选人”位置,每个进程必须先成为候选人,再判断能否进入数据库。利用候选人机制,即使数据库内有读者(数量不定),只要写者抢占了候选人位置,后到达的读者就不能进入数据库,同理后到达的写者也需等待。如果令因没抢到候选人而沉睡的进程按到达时间顺序排成队列,并且唤醒时按队列顺序进行,那么进程访问数据库的顺序就是时间顺序的,因此这个算法也被称为“读写平等”算法。举例来说,数据库内有进程,然后写者A到达、成为候选人、沉睡,多个读者到达、等待候选人位置、沉睡,写者B到达、等待候选人位置、沉睡,那么最后这些进程一定是按“写者A”、“读者群”、“写者B”的顺序进入数据库,也即按时间顺序进入的数据库,从而实现“读写平等”。

     

    //读写平等
    int candidate=1;  //候选人资格锁,1无候选人0有候选人
    int rd_count=0;    //读者数量
    int db_mutex=1;  //数据库锁,1开0闭
    int rdc_mutex=1;  //rd_count的锁,需要锁是因为候选人读者和欲离开数据库的读者都需要读写rd_count
    
    void reader()
    {
        while(true)
        {
            //欲进入数据库,先争夺候选人
            down(candidate);
            //成为候选人后,进入rd_count临界区
            down(rd_count);
            //若数据库内无读者,则自己是第一个读者,负责给数据库上锁,以防止写者进入
            if(rd_count==0)      
                down(db_mutex);
            //修改读者数量,离开rd_count临界区,解开候选人锁(因为自己进入数据库)
            rd_count++;
            up(rd_count);
            up(candidate);
    
            read_data();//数据库内操作
    
            //欲离开数据库,进入rd_count临界区,若自己是最后一个读者,解开数据库锁
            down(rd_count);
            rd_count--;
            if(rd_count==0)
                up(db_mutex);
            up(rd_count);
    
            use_data();  //数据库外操作
        }
    }    
    
    void writer()
    {
        while(true)
        {
            produce_data();  //数据库外操作
    
            //欲进入数据库,先夺得候选人资格
            down(candidate);
            //成为候选人后,给数据库上锁,以保证只有自己在内
            down(db_mutex);
            up(candidate);   //进入数据库,解开候选人资格锁
    
            write_data();   //数据库内操作
    
            //离开数据库,解开数据库锁
            up(db_mutex);   
         }
    }

     

     

       在某些特殊情况下,我们可能需要一个更加极端的读者-写者算法,那就是“写者优先”

      1.只要有写者在等待,想进入数据库的读者就必须等待

      2.数据库锁由锁上变为打开时,优先唤醒写者进程,不论是否有先于其到达的读者(从而打破了时间顺序,令写者有了优先权)

      回顾读写平等算法,可以发现当数据库锁解开时,离开数据库的要么是写者,要么是最后的读者。

      如果离开的是读者,而且有沉睡候选人,那么沉睡候选人一定是写者(读者不会因为数据库内有读者而沉睡),所以最后的读者只需要唤醒候选人即可保证“写者优先”。

      问题出在离开的是写者的情况,写者离开时的沉睡候选人既可能是写者,也可能是读者,但写者离开时并没有考虑这一点。因此要想实现写者优先,需要下手的是写者进程,让它们变得更为自己人考虑。

      在读者优先算法中,读者能“给自己人优先权”的根本原因在于读者掌控着数据库锁,只要读者不解开这把锁,写者就无法进入,但其它读者通过rd_count,得到了一定情况下无视数据库锁的“特权”。

      因此,一种类似的解法被提了出来:设置变量wt_count表示数据库内以及想进入数据库的写者总数,想进入数据库的写者先通过wt_count判断数据库内是否有写者,若无则竞争候选人,再等待数据库锁,若有则直接等待数据库锁;想离开数据库的写者直接释放数据库锁(若有等待中的写者,此后即可进入),再通过wt_count判断是否还有其他写者,若无则解开候选人锁,若有则直接走人,由“最后一个”写者负责解开候选人锁。这个想法就是利用候选人锁,使得写者得以“卡住”数据库、保证若有其他写者则将数据库让给其他写者。从而实现了“写者优先”

    //写者优先
    int candidate=1;  //候选人资格锁,1无候选人0有候选人
    int wt_count=0;   //数据库内及想进入数据库的写者数量
    int rd_count=0;    //数据库内的读者数量
    int db_mutex=1;  //数据库锁,1开0闭
    int rdc_mutex=1;  //rd_count的锁,需要锁是因为候选人读者和欲离开数据库的读者都需要读写rd_count
    int wtc_mutex=1; //wt_count的锁,需要锁是因为欲进入数据库的写者和欲离开数据库的写者都需要读写wt_count
    
    
    //reader进程与读写平等时相同
    void reader()
    {
        while(true)
        {
            //欲进入数据库,先争夺候选人
            down(candidate);
            //成为候选人后,进入rd_count临界区
            down(rd_count);
            //若数据库内无读者,则自己是第一个读者,负责给数据库上锁,以防止写者进入
            if(rd_count==0)      
                down(db_mutex);
            //修改读者数量,离开rd_count临界区,解开候选人锁(因为自己进入数据库)
            rd_count++;
            up(rd_count);
            up(candidate);
    
            read_data();//数据库内操作
    
            //欲离开数据库,进入rd_count临界区,若自己是最后一个读者,解开数据库锁
            down(rd_count);
            rd_count--;
            if(rd_count==0)
                up(db_mutex);
            up(rd_count);
    
            use_data();  //数据库外操作
        }
    }    
    
    void writer()
    {
        while(true)
        {
            produce_data();  //数据库外操作
    
            //欲进入数据库,先进入wt_count临界区,判断自己是否是“第一个”写者
            down(wtc_mutex);
            wt_count++;
            if(wt_count==1)   //若自己是“第一个”写者,则需要抢夺候选人
                down(candidate);
            down(db_mutex);  //不论自己是否是“第一个”写者,都需要等待数据库锁
            
            write_data();  //数据库内操作
    
            //欲离开数据库,直接解开数据库锁,再进入wt_count临界区,判断自己是否是“最后一个”写者
            up(db_mutex);
            down(wtc_mutex);
            wt_count--;
            if(wt_count==0)   //若自己是“最后一个”写者,则解开候选人锁,从而令读者有机会抢夺候选人、进入数据库
                up(candidate);
            up(wtc_mutex);
        }
    }

     

     

     

       ⑤避免编程失误的“管程”

      回顾三个经典进程通信问题,可以发现,信号量机制的确可以解决进程通信的问题,但是编程较为麻烦且容易出错造成死锁,以生产者-消费者问题为例,如果因为编程时的失误,某个生产者进程对信号量的操作顺序从

    down(empty);//减少一个空位置,若已为0则沉睡
    down(mutex);//进入临界区

      变成了

    down(mutex);//进入临界区
    down(empty);//减少一个空位置,若已为0则沉睡

      那么生产者就可能因为进入临界区后发现已无空位置而沉睡,并且没有解开mutex从而导致消费者没法取走产品,造成进程间的死锁。也就是说,通过直接对进程编程来使用信号量是“比较危险”的做法,一不小心就可能造成死锁等异常情况。因此,一种新的利用信号量实现进程通信的思想被提了出来:管程。

      通过对进程通信的分析,可以发现,同一类进程对信号量的操作是相同的,比如生-消问题中的生产者,都是执行如下代码

    down(empty);
    down(mutex);
    put_product();
    up(mutex);
    up(count);

      而消费者都是执行如下代码

    down(count);
    down(mutex);
    take_product();
    up(mutex);
    up(empty);

      那么,我们是否可以做出如下的一个独立的“模块”

    int empty=N;
    int count=0;
    
    void put_product(productType x)
    {
        down(empty);
        put(x);
        up(count);
    }
    
    void take_product(productType &x)
    {
        down(count);
        x=take();
        up(empty);
    }

      然后做出如下限制(为简便,put_product()简记为p(),take_product()简记为t()):

      1.同一时间只能有一个进程在执行p()或t(),其它调用了p()或t()的进程排队等待

      2.一个进程若在执行p()或t()时因为down()某个信号量而阻塞,则挂起,让等待执行p()或t()的另一个进程执行其调用的p()或t()

      3.一个进程若在执行p()或t()时因为up()某个信号量而唤醒了某沉睡进程,则在当前进程退出p()或t()后,令被唤醒进程执行其之前调用的p()或t()

      如果能实现这一限制,那么生产者和消费者就可以简单的完成自己想做的事,避免编程失误或者说方便进程通信出错时debug

    void producer()
    {
        productType x;
        while(true)
        {
            x=produce();  //生产产品
            put_product(x);  //放置产品
        }
    }
    
    void consumer()
    {
        productType x;
        while(true)
        {
            take_product(&x);  //取得产品
            consume(x);   //消费产品
        }
    }

      上述的所谓“模块”就是所谓的管程,习惯面向对象编程的人也可以将其视为一个类。之所以将这种实现进程通信的技术称之为管程,是因为在旁人看来,管程就是一个管理员,其负责保证同一时间只能有一个进程调用某些方式,并且负责这些进程的沉睡与唤醒。

      需要注意的是,就像信号量机制需要计算机底层的支持一样,管程也不是任意情况下均能实现。比如C语言就不可能实现管程,因为C语言无法满足管程需要的条件。但是有一些语言是可以实现管程的,比如JAVA,利用关键词synchronized,可以使同一个类中的某些方法不能被“同时”执行,借助此支持,再将生产者、消费者、管程写在同一个类中,就可以实现(线程级别的)管程思想。

     

     

     

      有关进程通信的基础知识就是上面这些,但是进程通信问题引出了另一个问题——死锁。虽然本文提到过死锁,但一直是在避免死锁的出现。那么死锁万一出现了,该如何令操作系统知晓呢?操作系统知道有死锁发生后,能不能解开死锁呢?这类问题与进程通信有关,但又自成一派,因此将其留作日后单独讨论。

    转载于:https://www.cnblogs.com/mm93/p/7705145.html

    展开全文
  • 如何制作微课.doc

    2019-06-20 11:04:51
    口语讲解,尽可能少地使用古板、枯燥书面语,使讲解通俗易懂。 9、PPT有视觉美感; 多角度地应用PPT现有功能带来视觉效果,如自定义动作、PPT切换、颜色搭配、字体搭配等。 10、视频画质清晰; 影响视频画质...
  • 剖析编程本质

    2021-04-09 19:22:53
    `# 第一节:剖析编程的...​ 为了兼顾所有在路上的朋友我尽量讲的通俗易懂,用非程序员的视角,以及程序员的思维方式一步步讲解. 什么是程序 ​ 程序从根本上来讲,就是程序员写出的指挥计算机进行特定运算的指令集. ​
  • Linux开发工具箱--项目开发最有效途径.pdf

    千次下载 热门讨论 2012-11-29 16:25:25
    《Linux开发工具箱:项目开发最有效途径》接下来对Linux内核基础知识和操作系统内部原理进行了详细且通俗易懂的阐述,并示范了如何将这些知识应用到更高级工具中。还重点讲解sar、vmstat、valgrind和strace等...
  • 第3部分:详细讲述了BPMN 2.0规范内容,包括目前Activiti对该规范实现情况,在讲解BPMN 2.0规范时,规范与Activiti实现进行结合,在通俗易懂的案例下,帮助读者对Activiti实现以及BPMN 2.0规范有更深入...
  •  《Linux开发工具箱:项目开发最有效途径0》接下来对Linux内核基础知识和操作系统内部原理进行了详细且通俗易懂的阐述,并示范了如何将这些知识应用到更高级工具中。还重点讲解sar、vmstat、valgrind和...
  • 如何快速解决复杂局域网故障,使局域网恢复令人满意服务,已成为一个十分重要课题。本课程让大家在熟悉... 课程编排重在实用,讲解通俗易懂。 让您在短期时间内学会最需要的知识点,为您节省宝贵时间。
  • 在职场办公中,我们经常需要对数据进行计算,在计算过程中使用公式会提高工作效率。本套课程将讲解这方面的知识。如公式组成结构、运算符、运算符优先级、单元格地址...】 课程编排重在实用,讲解通俗易懂