Tuesday, February 15, 2022

SegmentFault 最新的文章

SegmentFault 最新的文章


成为第一没有捷径:AI新势力MindSpore成长秘籍 | 源创者说 专访开源专家胡晓曼

Posted: 10 Feb 2022 10:42 PM PST

访谈者:马玮,SegmentFault 思否技术编辑

受访者:胡晓曼,华为MindSpore运营总监,LF AI & DATA Outreach Committee Chair

胡晓曼,华为昇思MindSpore运营专家,LF AI & DATA基金会Outreach委员会主席,中国电子学会专家库成员。TinyMS项目技术负责人,MSG·Women In Tech创始人。曾任汽车之家、百度等公司算法和运营团队负责人,负责多个深度学习落地项目及开源项目社区运营,入选「2021中国开源先锋 33 人」年度榜单。


https://www.bilibili.com/vide...

一个不懂技术的产品经理不是一个好的开源运营

2015年,刚刚从数学系毕业的胡晓曼成为了一名算法工程师。一边写代码,一边在博客网站上写机器学习算法原理,通俗易懂的方式很快就让她在技术圈里有了知名度。

2017年开始,胡晓曼撰写了"三个月从零入门深度学习"的技术教程,迅速在博客园成为了知名技术博主,点击量超500w,同时也受邀开始不定期的各类线下分享。

2019年,胡晓曼在技术分享道路上再进一步,成为了一名"深度学习布道师",帮助更多开发者迅速踏入深度学习世界的大门。在这一过程中胡晓曼与华为结缘,在2020年加入华为负责华为全场景AI框架MindSpore的开源社区运作与管理。

"在华为,我可以从全盘的视角思考一个开源社区如何完成从0到1的成长过程。"

与技术布道师的角色不同,在华为的职业生涯最吸引胡晓曼的是她能够从一开始就参与到一个新兴开源社区的成长当中。对于胡晓曼自己来说,从程序员的岗位跨界到社区运营也是一种新的突破。一位跨界人与一个年轻的社区共同褪去青涩,走向成熟,的确称得上难得而宝贵的经历。

懂技术,更要懂用户

如今,MindSpore社区已经拥有超过80万用户,4000名贡献者,社区下载量超过100万。从0到1带领开源社区迎来这样的繁荣景象,胡晓曼总结了如下几点经验:

1. 扎实的技术功底

算法专家出身的胡晓曼具备扎实的技术功底。MindSpore框架的上手和使用对胡晓曼来说颇为轻松,她也能深度参与和理解开发者讨论的各类技术主题。在她看来,开源技术社区的运营至少应该能独立使用运营的产品,最好能深入理解代码和原理相关的内容。具备这样的技术背景,运营才能更好地与社区用户打成一片,亲身感受社区氛围,了解用户的需求与痛点。

2. 多样化的运营方式

胡晓曼认为,开源社区的运营需要与该项目的发展周期相结合,在开源初期,她把目标定为"迅速提升技术影响力"。MindSpore是一个迭代速度非常快的AI框架,在开源初期,每个月月末都会发布新版本,让开发者体验新的版本特性,如何让开发者们更快了解这些新特性的优势,是她最先解决的问题之一。

在不断尝试的过程中,胡晓曼将各类互联网社区的成功经验融入进来,于是开始利用视频、趣味课程等方式来吸引用户,获得了相当好的效果。技术视频往往较为枯燥乏味,胡晓曼将开发者的痛点和新版本的特性结合在一块,拍摄了1分钟新版本特性短视频,发布在抖音、B站等各个网站。在没有任何渠道推广的情况下,全网播放量突破百万,迅速打出了MindSpore的技术品牌。算法专家背景出身的她,将以往模型训练的经验,运用到开源运营上,首次提出了"用模型的思维做运营"的方法论,把每一次运营的方法作为一次小的模型,按照整个"模型开发"流程一样不断地迭代优化,上线后进行效果评估、收集社区反馈,在下一次改进时去芜存菁,这样的进化方式成为了MindSpore社区快速成长的主要驱动力之一。

3. 平等友好的社区氛围

除了技术背景和多样化的手段以外,胡晓曼提到了开源社区建设工作中最关键的价值点。

"真正的开源社区中,运营方与开发者应该处于互相平等的地位。"

很多开源技术的社区管理方会有一种居高临下的心理认知,认为开发者只是"贡献者"的角色,将自身置于合作的主导地位上,甚至会有大家长的心态。但胡晓曼认为,社区运营与用户的地位一定应该是平等开放的,这样才能让用户体会到自己的参与和贡献具有价值感。

这种平等开放的理念贯穿在了MindSpore社区的发展过程中,并得到了广大社区成员的认可与支持。不少开发者会持续提出自己的建议,运营方会很快给出相应的反馈,这样的交流推动了整个社区的成长。这样的社区氛围也让胡晓曼获得了更多成就感,让她体会到自己的工作为成千上万的开发者、社区成员确确实实地带来了收益。

一个开源社区是否成功,是很难去量化的,上游社区的开源社区健康度指标并没有一个标准的评价体系,即使有也很难适用于所有的开源社区。其他的平台性质指标,譬如GitHub的star、fork、watch等,更不是衡量一个开源社区成功的唯一标准,那么选择什么样的目标作为考量指标,决定了这个开源社区未来的走向和价值定位。在这个过程中,需要反复地去验证、磨合,证明,确保MindSpore开源社区的定位,是完全以开发者为本的社区,而不是唯指标论的社区。指标是用来衡量自己的工作的,不是来单一衡量开发者社区的唯一目标。

产品思维做社区,成!

1. ToB新范式:MSG·企业行为什么比传统模式具有更高的转化率

开源产品对于企业是否能带来商业价值一直是业界的思考问题。传统的ToB模式需要投入大量的人力和时间,周期长见效慢,如何更有效更迅速地了解企业的痛点和诉求,是胡晓曼想突破的核心点所在。2021年下半年,她设计了一套完整的MindSpore企业拓展方案,面对不同类型、行业、所处阶段的企业均有针对性方案。今年7月开启了第一场MSG企业行·南京场,联合江苏昇腾创新中心,覆盖了96%南京本地AI科技企业,针对优势行业进行了企业赋能,同时让他们了解MindSpore的技术优势、计算中心和创新中心的扶持力度,给当地的中小型AI企业搭建了合作的桥梁。后续陆续将企业行的成功模式带入了武汉、西安、成都。为500多家高意向企业建立了合作的渠道,大力节省了企业本身需要在AI人才、算力等方面需要耗费的资金,具有显著的商业价值。

2. 项目交付制:TinyMS如何在开源半年内助力团队获得比赛金奖?

去年3月末,胡晓曼带领团队开发了新型AI工具——基于MindSpore开发的高阶API工具TinyMS。在项目启动之初,胡晓曼就立志想要为AI初学者设计一个完全零基础也能上手使用的高阶AI框架工具,并配备保姆级的教程,帮助小白从最基础的shell脚本、python学习、必备的数学知识等开始学,直到能自己上手写模型。10月,第七届中国国际"互联网+"大学生创新创业大赛总决赛在南昌开幕,来自华南理工大学的团队选择了TinyMS赛题——『使用MindSpore高阶工具TinyMS支持的网络CycleGAN训练图像风格迁移模型』,在6100个项目中、12000余名学生的激烈角逐下获得了产业赛道唯一金奖。事后采访获奖的两位同学代表,均认为正是因为TinyMS的极致易用,才让他们在如此短的时间内完成开发和调试,最终摘得桂冠。胡晓曼始终认为,在开源社区的运作和管理中,对于任何项目,都要有清晰明确的定位和规划,并采取项目交付制,满足最小MVP原则先上线,再不断优化迭代,才能尽可能地让产品更加完善,满足用户需求。

3. 生态永传承:两大生态案例落地的背后故事

2021年4月,一个下午的时间,山水自然保护中心和MindSpore社区一拍即合,决定为国家保护动物做件大事,合作三江源野外红外相机识别项目。历经半年反复调优及野外实地实验,2021年底终于在山水自然保护生态系统上线。这是首个基于国产框架的三江源红外影像自然保护预训练模型,并且开源所有相关工具。

除了生态保护,文化传承也少不了昇思MindSpore的助力。在胡晓曼开展的MSG·Women In Tech活动中,AI和加密艺术家宋婷女士想借助AI为文化传承尽一份力,宋婷团队基于MindSpore的GAN网络,对世界保护非物质文化遗产扎染的图案进行训练,生成新的扎染图案来保护非传承工艺,最终在央视作为开源文化特色案例播出。

很多非AI领域的人往往不知道AI能为人们的生活带来什么改变,能够为大自然做出什么贡献,这两个案例生动形象展示了昇思MindSpore AI框架为自然生态和文化领域带来的贡献,这也是胡晓曼认为投入开源的最大价值。

想入行?这么做

如何将自身打造成一名优秀的社区运营专家呢?胡晓曼回顾自身经验,提出了三点供大家参考。

1. 保持初心,与开发者感同身受

当你在进入一个新的领域时,一定会有手足无措的感觉,但是熟悉了以后,就很难体会到当初作为小白的心境了。所以要时刻保持自己的新鲜感,与开发者感同身受,去回顾当时的心境。除了日常工作外,胡晓曼会经常学习不同的技术框架,如Julia等,了解其他不同类别的技术社区的发展历史,技术特性的更新,和社区氛围的建设,这样能帮助她跳出原本的AI领域,从另一个角度去看待原本的工作是否有值得学习的点。此外,为了保持与开发者同频率的心境,胡晓曼还会定期和开发者聊天,了解他们在使用产品时的体验,真正做到零距离面对开发者。

2. 广泛涉猎,迅速转化灵感并执行

胡晓曼阅读的范围非常广泛,除了早期做程序员的时候经常刷的工具书,哲学、自然科学、社会心理学等各种类型都有涉猎,并在自己的读书笔记中记录值得思考的点。在阅读不同行业、不同领域的书籍时,她经常会和工作中的应用场景结合在一起,引出一些新的思考,然后把思考转化为可操作的执行方案,再来快速迭代,最后抽象成一条可行的方法论,在以后类似的场景进行复用。除此之外,她还会体验各类不同的互联网社区,包括小红书、抖音等各类内容平台,了解各平台是如何吸引用户自发产生内容,进行流量分发、设立激励机制等等。虽然是不同的产品,但是成功的产品一定会有共性,能够总结出通用的一些经验,尤其在把握人性方面,这些全品类的内容平台把这一点运用到了极致。对开源社区,除了技术产品本身的实力需要足够过硬,好的内容吸引机制也是社区运营必不可少的因素之一。

3. 量化指标,不断提升工作效果

如果让你尝试一项新事物,不定任何目标和截止时间,很容易随着时间的推移拖延,或者因为长期看不到进步而放弃。所以胡晓曼提出,对待工作,或者新事物,要学会量化学习,具化指标,定量完成,阶段更新。为什么会得出这个结论呢?其实在日常生活中,胡晓曼热爱健身,积极尝试各种运动,包括羽毛球、普拉提、滑雪等,在认真学习过多种运动后,她发现健身,或者说不同的运动是可以量化学习的。譬如健身,很多人在没有系统学习和科学的方法之前直接上大重量就很容易受伤,而不定目标,每次泛泛的学习,长期下来也会有挫败感。同理,当开发者接触一个新的领域,新的框架,如果没有阶段性的反馈,很难去发现自己存在的问题,而人往往都具有惰性,所以胡晓曼在社区中设计了一个开发者进阶体系,让所有进入到MindSpore开源社区的开发者都能清楚的找到自己的定位,引导激励开发者不断再向前一步,这样能让每一位开发者在社区中不断成长。在个人工作中,胡晓曼也经常会量化自己的工作指标,用数据定量反馈成果,而不是用体验去描述结果的好坏。

主流之外,更要关注

1. 关注女性开发者:来自女性的视角与见解

策划女性开发者主题的线下沙龙活动是胡晓曼运营工作中的一项独到成果,她是国内第一个在开源社区中格外关注女性开发者的人。女性程序员出身的胡晓曼更容易理解IT技术行业中女性作为少数力量所普遍存在的心理认知与担忧。例如很多女性会对自身的职业前景、职业发展有种种疑虑,于是胡晓曼专门策划了MSG·Women In Tech的女性专场活动,针对科技领域的女性从业者,为她们组建一个独有的空间,让行业中的女性前辈分享自己的案例,解决职场上发展的困惑。

常见的技术沙龙往往是布道式的,几位专家发表演讲,与会者认真听讲,互动部分只占很小的比例。但MSG·Women in Tech不再是单一的听,而是与嘉宾完全互动,和参会的人共同探讨职业问题。目前为止,共举办了4期活动,分别在北京、上海、深圳邀请了二十多位优秀女性行业前辈分享经验,吸引了300+位女性科技从业者报名,100+位女性来到现场互相交流。分享如何成为一个合格的技术leader、如何向上管理、如何争取自己想做的项目等等一系列在职场中的常见问题,与会者和嘉宾围着桌子坐成一圈,大家一同去探索解决方案,分享经验,在去年9月作为华为Women In Tech官方代表,拍摄视频发布于华为海内外官方账号,传播量超千万,受到广泛的认可。

2. 开源运营也开源:能开源的绝不止代码

在开源社区里,常见的开源项目通常以代码为主,但胡晓曼想,为什么我们不能把开源运营也开源出来呢?不是只有代码经验才可以复用,开源社区运营本身也可以总结成经验形成方法论再开源出来,造福新入行的同学。去年三月末,MindSpore开源社区联合开放原子基金会,共同发起了一个『把社区运营也开源』的开源社区0xCommops,希望大家共同贡献,持续完善开源社区运营体系,打造一个开源社区运营的知识库、工具库。

一些小建议

很多年轻的技术从业者甚至非技术背景的年轻人都对开源社区运营这样的岗位感兴趣,胡晓曼也为这些小伙伴提出了宝贵的建议。

  • 了解行业。胡晓曼建议,想要在开源社区运营的岗位上做出成绩,运营者首先就要有非常扎实的技术背景,足够了解自己要运营的产品。一个合格的开源社区运营者,必须是一个有产品思维的资深开发者。
  • 快速试错。运营类的工作并不一定要进入公司才可以做,如果你是一个程序员,可以先从运营一个自己的开源项目入手,从0到1的尝试做到活跃社区,总结有效的经验,小步快走,快速试错,对正式进入开源运营的工作非常有帮助。
  • 保持极大的热情和充沛的精力。从事一个你热爱的职业是一件非常难得的事,如果正好是,那很幸运,保持你的初心和热情,如果不是,那把它变成你热爱的工作,找到工作中的兴奋点,这样才能走的更远。运营的职业发展是有很多可能性的,在工作中需要不断去思考和探索,并结合自身去修正职业图景。随着自身眼界不断开阔、成就越来越多,获得知识和经验的持续积累,运营人员也会对自身优劣势有更清晰的认知。在此基础上,小伙伴就可以从自己的兴趣出发,选择最适合自己的发展方向。

总而言之,热爱自己的工作,把它作为事业而不是职业,坚定的走下去,会让你走的更远,带来意想不到的价值!


关于华为开源

作为可信赖的开源公民,华为通过持续贡献,携手伙伴,提倡包容、公平、开放和更团结的协作,共建世界级基础软件开源社区,加速行业数字化进程。

· 主流开源组织的积极参与者和支持者。目前华为已是数十个国际开源基金会的顶级/初创会员。

·规模贡献开源基础软件,夯实数字基础设施生态底座,携手伙伴、开发者共建开源生态。近两年来,面向云原生、自动化和智能化,华为先后开源了KubeEdge 、MindSpore 、openEuler、openGauss、OpenHarmony等多个平台级基础软件开源项目,成为被全球开发者所接受的开源社区,并在各行业商用落地。

· 积极建设可持续发展、有生命力的可信开源社区。华为致力于完善社区生态治理架构,确保社区持续演进。

关注华为开源公众号,了解更多!

点击此处,进入华为开源官网了解更多

给面试加点硬菜:延迟任务场景,该如何提高吞吐量和时效性!

Posted: 14 Feb 2022 06:00 PM PST

作者:小傅哥
博客:https://bugstack.cn

沉淀、分享、成长,让自己和他人都能有所收获!😄

一、前言

不卷了,能用就行!

哈哈哈,说好的不卷了,能凑活用就行了。但每次接到新需求时都手痒,想结合着上一次的架构设计和落地经验,在这一次需求上在迭代更新,或者找到完全颠覆之前的更优方案。卷完代码的那一刻总是神清气爽

其实大部分喜欢写代码的一类纯粹码农,都是比较卷的,就比如一个需求在实现上是能用大概是P5、如果这个做出来的功能不只是能用还非常好用是P6、除了好用还凝练共性需求开发成通用的组件服务是P7。每一个成长过来的码农,都是在造轮子的路上一次次验证自己的想法和加以实践,绝对不是一篇篇的八股文就能累出来一个高级的技术大牛。

二、延迟任务场景

什么是延迟任务?

当我们的实际业务需求场景中,有一些活动开始前的状态变更、订单结算后的T+1对账、贷款单息费的产生,都是需要使用到延迟任务来进行触达。实际的操作一般会有 Quartz、Schedule 来对你的库表数据进行定时扫描和处理,当条件满足后做数据状态的变更或者产生新的数据插入到表中。

这样一个简单的需求就是延迟任务最初需求,如果需求前期内容较少、使用方不多,可能在实际开发中就只是一个单台机器直接对着表一顿轮训就完事了。但随着业务需求的发展和功能的复杂度提升,往往反馈到研发设计和实现,就不那么简单了,比如:你需要保障尽可能低延迟完成较大规模的数据量扫描处理,否则就像贷款单息费的产生,已经到了第二天用户还没看到自己的息费信息或者是还款后的重新对账,可能就这个时候就要产生客诉了。

那么,类似这样的场景该如何设计呢?

三、延迟任务设计

通常的任务中心处理流程主要,主要是由定时任务扫描任务库表,把即将达到超时时间的任务信息扫描到处理队列(内存/MQ消息),再由业务系统进行处理任务,处理完成后更新库表中的任务状态。

高延时任务调度

问题

  1. 海量数据规模较大的任务列表数据,在分库分表下该需要快速扫描。
  2. 任务扫描服务与业务逻辑处理,耦合在一起,不具有通用性和复用性。
  3. 细分任务体系有些是需要低延迟处理的,不能等待过长时间。

1. 任务表方式

除了一些较小的状态变更场景,例如在各自业务的库表中,就包含了一个状态字段,这个字段一方面有程序逻辑处理变更的状态,也有到达指定到期时间后由任务服务自动变更处理的操作,一般这类功能,直接设计到自己的库表中即可。

那么还有一些较大也较为频繁使用的场景,如果都是在每个系统的各自所需的N多个表中,都添加这样的字段进行维护,就显得非常冗余了,也不那么易于维护。所以针对这样的场景就很适合做一个通用的任务延时系统,各业务系统把需要被延时执行的动作提交到延时系统中,再有延时系统在指定时间下进行回调,回调的动作可以是接口或者MQ消息进行触达。例如可以设计这样一个任务调度表:

任务调度库表设计

  1. 抽取的任务调度表,主要是拿到什么任务,在什么时间发起动作,具体的动作处理仍交给业务工程处理。
  2. 大批量的各自业务的任务进行集中处理,则需要设计一个分库分表,满足于后续业务体量的增长。
  3. 门牌号设计,针对一张表的扫描,如果数据量较大,又不希望只是一个任务扫描一个表,可以多个任务扫描一个表,加到扫描的体量。这个时候就需要一个门牌号来隔离不同任务扫描的范围,避免扫描出重复的任务数据。

2. 低延迟方式

低延迟处理方案,是在任务表方式的基础上,新增加的时间把控处理。它可以把即将到期的前一段时间的任务,放置到 Redis 集群队里中,在消费的时候再从队列中 pop 出来,这样可以更快的接近任务的处理时效,避免因为扫库间隔较大延迟任务执行。

任务处理流程

  • 在接收业务系统提交进来的延迟任务时,按照执行时间的长短放置到任务库或者也同步到 Redis 集群中,一些执行时间较晚的任务则可以先放到任务库,再通过扫描的方式添加到超时任务执行队列中。
  • 那么关于这块的设计核心在于 Redis 队列的使用,以及为了保证消费的可靠性需要引入二阶段消费、注册 ZK 注册中心至少保证一次消费的处理。本文重点主要放在 Redis 队列的设计,其他更多的逻辑处理,可以按照业务需求进行扩展和完善

Redis 消费队列

Redis 消费队列

  • 按照消息体计算对应数据所属的槽位 index = CRC32 & 7
  • StoreQueue 采用 Slot 按照 SlotKey = #{topic}_#{index} 和 Sorted Set 的数据结构按执行任务分数排序,存放任务执行信息。定时消息将时间戳作为分数,消费时每次弹出分数小于当前时间戳的一个消息
  • 为了保障每条消息至少可消费一次,消费者不是直接 pop 有序集合中的元素,而是将元素从 StoreQueue 移动到 PrepareQueue 并返回消息给消费者。消费成功后再从 PrepareQueue 从删除,如果消费失败则从PreapreQueue 重新移动到 StoreQueue,这样二阶段消费的方式进行处理。
  • 参考文档:2021 阿里技术人的百宝黑皮书PDF文,低延迟的超时中心实现方式

简单案例

@Test public void test_delay_queue() throws InterruptedException {     RBlockingQueue<Object> blockingQueue = redissonClient.getBlockingQueue("TASK");     RDelayedQueue<Object> delayedQueue = redissonClient.getDelayedQueue(blockingQueue);     new Thread(() -> {         try {             while (true){                 Object take = blockingQueue.take();                 System.out.println(take);                 Thread.sleep(10);             }         } catch (InterruptedException e) {             e.printStackTrace();         }     }).start();     int i = 0;     while (true){         delayedQueue.offerAsync("测试" + ++i, 100L, TimeUnit.MILLISECONDS);         Thread.sleep(1000L);     } }

测试数据

2022-02-13  WARN 204760 --- [      Finalizer] i.l.c.resource.DefaultClientResources    : io.lettuce.core.resource.DefaultClientResources was not shut down properly, shutdown() was not called before it's garbage-collected. Call shutdown() or shutdown(long,long,TimeUnit)  测试1 测试2 测试3 测试4 测试5  Process finished with exit code -1

四、总结

  • 调度任务的使用在实际的场景中非常频繁,例如我们经常使用 xxl-job,也有一些大厂自研的分布式任务调度组件,这些可能原本都是很小很简单的功能,但经过抽象、整合、提炼,变成了一个个核心通用的中间件服务。
  • 当我们在考虑使用任务调度的时候,无论哪种方式的设计和实现,都需要考虑这个功能使用时候的以为迭代和维护性,如果仅仅是一个非常小的场景,又没多少人使用的话,那么在自己机器上折腾就可以。过渡的设计和使用有时候也会把研发资源代入泥潭
  • 其实各项技术的知识点,都像是一个个工具,刀枪棍棒斧钺钩,那能怎么结合各自的特点,把这些兵器用起来,才是一个程序员不断成长的过程。如果你希望了解更多此类有深度的技术内容,可以加入 Lottery 分布式抽奖秒杀系统 学习更有价值的更抗用的实战手段。

五、系列推荐

当Synchronized遇到这玩意儿,有个大坑,要注意!

Posted: 13 Feb 2022 08:37 PM PST

你好呀,我是歪歪。

前几天在思否上看到别人提的关于 Synchronized 的一个用法问题,我觉得挺有意思的,这个问题其实也是我三年前面试某公司的时候遇到的一个真题,当时不知道面试官想要考什么,没有回答的特别好,后来研究了一下就记住了。

所以看到这个问题的时候觉得特别亲切,准备分享给你一起看看:

首先为了方便你看文章的时候复现问题,我给你一份直接拿出来就能跑的代码,希望你有时间的话也把代码拿出来跑一下:

public class SynchronizedTest {      public static void main(String[] args) {         Thread why = new Thread(new TicketConsumer(10), "why");         Thread mx = new Thread(new TicketConsumer(10), "mx");         why.start();         mx.start();     } }  class TicketConsumer implements Runnable {      private volatile static Integer ticket;      public TicketConsumer(int ticket) {         this.ticket = ticket;     }      @Override     public void run() {         while (true) {             System.out.println(Thread.currentThread().getName() + "开始抢第" + ticket + "张票,对象加锁之前:" + System.identityHashCode(ticket));             synchronized (ticket) {                 System.out.println(Thread.currentThread().getName() + "抢到第" + ticket + "张票,成功锁到的对象:" + System.identityHashCode(ticket));                 if (ticket > 0) {                     try {                         //模拟抢票延迟                         TimeUnit.SECONDS.sleep(1);                     } catch (InterruptedException e) {                         e.printStackTrace();                     }                     System.out.println(Thread.currentThread().getName() + "抢到了第" + ticket-- + "张票,票数减一");                 } else {                     return;                 }             }         }     } }

程序逻辑也很简单,是一个模拟抢票的过程,一共 10 张票,开启两个线程去抢票。

票是共享资源,且有两个线程来消费,所以为了保证线程安全,TicketConsumer 的逻辑里面用了 synchronized 关键字。

这是应该是大家在初学 synchronized 的时候都会写到的例子,期望的结果是 10 张票,两个人抢,每张票只有一个人能抢到。

但是实际运行结果是这样的,我只截取开始部分的日志:

截图里面有三个框起来的部分。

最上面的部分,就是两个人都在抢第 10 张票,从日志输出上看也完全没有任何毛病,最终只有一个人抢到了票,然后进入到第 9 张票的争夺过程。

但是下面被框起来的第 9 张票的争夺部分就有点让人懵逼了:

why抢到第9张票,成功锁到的对象:288246497 mx抢到第9张票,成功锁到的对象:288246497

为什么两个人都抢到了第 9 张票,且成功锁到的对象都一样的?

这玩意,超出认知了啊。

这两个线程怎么可能拿到同一把锁,然后去执行业务逻辑呢?

所以,提问者的问题就浮现出来了。

  • 1.为什么 synchronized 没有生效?
  • 2.为什么锁对象 System.identityHashCode 的输出是一样的?

为什么没有生效?

我们先来看一个问题。

首先,我们从日志的输出上已经非常明确的知道,synchronized 在第二轮抢第 9 张票的时候失效了。

经过理论知识支撑,我们知道 synchronized 失效,肯定是锁出问题了。

如果只有一把锁,多个线程来竞争同一把锁,synchronized 绝对是不会有任何毛病的。

但是这里两个线程并没有达成互斥的条件,也就是说这里绝对存在的不止一把锁。

这是我们可以通过理论知识推导出来的结论。

先得出结论了,那么我怎么去证明"锁不止一把"呢?

能进入 synchronized 说明肯定获得了锁,所以我只要看各个线程持有的锁是什么就知道了。

那么怎么去看线程持有什么锁呢?

jstack 命令,打印线程堆栈功能,了解一下?

这些信息都藏在线程堆栈里面,我们拿出来一看便知。

在 idea 里面怎么拿到线程堆栈呢?

这就是一个在 idea 里面调试的小技巧了,我之前的文章里面应该也出现过多次。

首先为了方便获取线程堆栈信息,我把这里的睡眠时间调整到 10s:

跑起来之后点击这里的"照相机"图标:

点击几次就会有对应点击时间点的几个 Dump 信息

由于我需要观察前两次锁的情况,而每次线程进入锁之后都会等待 10s 时间,所以我就在项目启动的第一个 10s 和第二个 10s 之间各点击一次就行。

为了更直观的观察数据,我选择点击下面这个图标,把 Dump 信息复制下来:

复制下来的信息很多,但是我们只需要关心 why 和 mx 这两个线程即可。

这是第一次 Dump 中的相关信息:

mx 线程是 BLOCKED 状态,它在等待地址为 0x000000076c07b058 的锁。

why 线程是 TIMED_WAITING 状态,它在 sleeping,说明它抢到了锁,在执行业务逻辑。而它抢到的锁,你说巧不巧,正是 mx 线程等待的 0x000000076c07b058。

从输出日志上来看,第一次抢票确实是 why 线程抢到了:

从 Dump 信息看,两个线程竞争的是同一把锁,所以第一次没毛病。

好,我们接着看第二次的 Dump 信息:

这一次,两个线程都在 TIMED_WAITING,都在 sleeping,说明都拿到了锁,进入了业务逻辑。

但是仔细一看,两个线程拿的锁是不相同的锁。

mx 锁的是 0x000000076c07b058。

why 锁的是 0x000000076c07b048。

由于不是同一把锁,所以并不存在竞争关系,因此都可以进入 synchronized 执行业务逻辑,所以两个线程都在 sleeping,也没毛病。

然后,我再把两次 Dump 的信息放在一起给你看一下,这样就更直观了:

如果我用"锁一"来代替 0x000000076c07b058,"锁二"来代替 0x000000076c07b048。

那么流程是这样的:

why 加锁一成功,执行业务逻辑,mx 进入锁一等待状态。

why 释放锁一,等待锁一的 mx 被唤醒,持有锁一,继续执行业务。

同时 why 加锁二成功,执行业务逻辑。

从线程堆栈中,我们确实证明了 synchronized 没有生效的原因是锁发生了变化。

同时,从线程堆栈中我们也能看出来为什么锁对象 System.identityHashCode 的输出是一样的。

第一次 Dump 的时候,ticket 都是 10,其中 mx 没有抢到锁,被 synchronized 锁住。

why 线程执行了 ticket-- 操作,ticket 变成了 9,但是此时 mx 线程被锁住的 monitor 还是 ticket=10 这个对象,它还在 monitor 的 _EntryList 里面等着的,并不会因为 ticket 的变化而变化。

所以,当 why 线程释放锁之后,mx 线程拿到锁继续执行,发现 ticket=9。

而 why 也搞到一把新锁,也可以进入 synchronized 的逻辑,也发现 ticket=9。

好家伙,ticket 都是 9, System.identityHashCode 能不一样吗?

按理来说,why 释放锁一后应该继续和 mx 竞争锁一,但是却不知道它在哪搞到一把新锁。

那么问题就来了:锁为什么发生了变化呢?

谁动了我的锁?

经过前面一顿分析,我们坐实了锁确实发生了变化,当你分析出这一点的时候勃然大怒,拍案而起,大喊一声:是哪个瓜娃子动了我的锁?这不是坑爹吗?

按照我的经验,这个时候不要急着甩锅,继续往下看,你会发现小丑竟是自己:

抢完票之后,执行了 ticket-- 的操作,而这个 ticket 不就是你的锁对象吗?

这个时候你把大腿一拍,恍然大悟,对着围观群众说:问题不大,手抖而已。

于是大手一挥,把加锁的地方改成这样:

synchronized (TicketConsumer.class)

利用 class 对象来作为锁对象,保证了锁的唯一性。

经过验证也确实没毛病,非常完美,打完收工。

但是,真的就收工了吗?

其实关于锁对象为什么发生了变化,还隔了一点点东西没有说出来。

它就藏在字节码里面。

我们通过 javap 命令,反查字节码,可以看到这样的信息:

Integer.valueOf 这是什么玩意?

让人熟悉的 Integer 从 -128 到 127 的缓存。

也就是说我们的程序里面,会涉及到拆箱和装箱的过程,这个过程中会调用到 Integer.valueOf 方法。具体其实就是 ticket-- 的这个操作。

对于 Integer,当值在缓存范围内的时候,会返回同一个对象。当超过缓存范围,每次都会 new 一个新对象出来。

这应该是一个必备的八股文知识点,我在这里给你强调这个是想表达什么意思呢?

很简单,改动一下代码就明白了。

我把初始化票数从 10 修改为 200,超过缓存范围,程序运行结果是这样的:

很明显,从第一次的日志输出来看,锁都不是同一把锁了。

这就是我前面说的:因为超过缓存范围,执行了两次 new Integer(200) 的操作,这是两个不同的对象,拿来作为锁,就是两把不一样的锁。

再修改回 10,运行一次,你感受一下:

从日志输出来看,这个时候只有一把锁,所以只有一个线程抢到了票。

因为 10 是在缓存范围内的数字,所以每次是从缓存中获取出来,是同一个对象。

我写这一小段的目的是为了体现 Integer 有缓存这个知识点,大家都知道。但是当它和其他东西揉在一起的时候因为这个缓存会带来什么问题,你得分析出来,这比直接记住干瘪的知识点有效一点。

但是...

我们的初始票是 10,ticket-- 之后票变成了 9,也是在缓存范围内的呀,怎么锁就变了呢?

如果你有这个疑问的话,那么我劝你再好好想想。

10 是 10,9 是 9。

虽然它们都在缓存范围内,但是本来就是两个不同的对象,构建缓存的时候也是 new 出来的:

为什么我要补充这一段看起来很傻的说明呢?

因为我在网上看到其他写类似问题的时候,有的文章写的不清楚,会让读者误认为"缓存范围内的值都是同一个对象",这样会误导初学者。

总之一句话:请别用 Integer 作为锁对象,你把握不住。

但是...

stackoverflow

但是,我写文章的时候在 stackoverflow 上也看到了一个类似的问题。

这个哥们的问题在于:他知道 Integer 不能做为锁对象,但是他的需求又似乎必须把 Integer 作为锁对象。

https://stackoverflow.com/que...

我给你描述一下他的问题。

首先看标号为 ① 的地方,他的程序其实就是先从缓存中获取,如果缓存中没有则从数据库获取,然后在放到缓存里面去。

非常简单清晰的逻辑。

但是他考虑到并发的场景下,如果有多个线程同一时刻都来获取同一个 id,但是这个 id 对应的数据并没有在缓存里面,那么这些线程都会去执行查询数据库并维护缓存的动作。

对应查询和存储的动作,他用的是 fairly expensive 来形容。

就是"相当昂贵"的意思,说白了就是这个动作非常的"重",最好不要重复去做。

所以只需要让某一个线程来执行这个 fairly expensive 的操作就好了。

于是他想到了标号为 ② 的地方的代码。

用 synchronized 来把 id 锁一下,不幸的是,id 是 Integer 类型的。

在标号为 ③ 的地方他自己也说了:不同的 Integer 对象,它们并不会共享锁,那么 synchronized 也没啥卵用。

其实他这句话也不严谨,经过前面的分析,我们知道在缓存范围内的 Integer 对象,它们还是会共享同一把锁的,这里说的"共享"就是竞争的意思。

但是很明显,他的 id 范围肯定比 Integer 缓存范围大。

那么问题就来了:这玩意该咋搞啊?

我看到这个问题的时候想到的第一个问题是:上面这个需求我好像也经常做啊,我是怎么做的来着?

想了几秒恍然大悟,哦,现在都是分布式应用了,我特么直接用的是 Redis 做锁呀。

根本就没有考虑过这个问题。

如果现在不让用 Redis,就是单体应用,那么怎么解决呢?

在看高赞回答之前,我们先看看这个问题下面的一个评论:

开头三个字母:FYI。

看不懂没关系,因为这个不是重点。

但是你知道的,我的英语水平 very high,所以我也顺便教点英文。

FYI,是一个常用的英文缩写,全称是 for your information,供参考的意思。

所以你就知道,他后面肯定是给你附上一个资料了,翻译过来就是: Brian Goetz 在他的 Devoxx 2018 演讲中提到,我们不应该把 Integer 作为锁。

你可以通过这个链接直达这一部分的讲解,只有不到 30s秒的时间,随便练练听力:https://www.youtube.com/watch...

那么问题又来了?

Brian Goetz 是谁,凭什么他说的话看起来就很权威的样子?

Java Language Architect at Oracle,开发 Java 语言的,就问你怕不怕。

同时,他还是我多次推荐过的《Java并发编程实践》这本书的作者。

好了,现在也找到大佬背书了,接下来带你看看高赞回答是怎么说的。

前部分就不详说了,其实就是我们前面提到的那一些点,不能用 Integer ,涉及到缓存内、缓存外巴拉巴拉的...

关注划线的部分,我加上自己的理解给你翻译一下:

如果你真的必须用 Integer 作为锁,那么你需要搞一个 Map 或 Integer 的 Set,通过集合类做映射,你就可以保证映射出来的是你想要的明确的一个实例。而这个实例,就那可以拿来做锁。

然后他给出了这样的代码片段:

就是用 ConcurrentHashMap 然后用 putIfAbsent 方法来做一个映射。

比如多次调用 locks.putIfAbsent(200, 200),在 map 里面也只有一个值为 200 的 Integer 对象,这是 map 的特性保证的,无需过多解释。

但是这个哥们很好,为了防止有人转不过这个弯,他又给大家解释了一下。

首先,他说你也可以这样的写:

但这样一来,你就会多产生一个很小成本,就是每次访问的时候,如果这个值没有被映射,你都会创建一个 Object 对象。

为了避免这一点,他只是把整数本身保存在 Map 中。这样做的目的是什么?这与直接使用整数本身有什么不同呢?

他是这样解释的,其实就是我前面说的"这是 map 的特性保证的":

当你从 Map 中执行 get() 时,会用到 equals() 方法比较键值。

两个相同值的不同 Integer 实例,调用 equals() 方法是会判定为相同的 。

因此,你可以传递任何数量的 "new Integer(5)" 的不同 Integer 实例作为 getCacheSyncObject 的参数,但是你将永远只能得到传递进来的包含该值的第一个实例。

就是这个意思:

汇总一句话:就是通过 Map 做了映射,不管你 new 多少个 Integer 出来,这多个 Integer 都会被映射为同一个 Integer,从而保证即使超出 Integer 缓存范围时,也只有一把锁。

除了高赞回答之外,还有两个回答我也想说一下。

第一个是这个:

不用关心他说的内容是什么,只是我看到这句话翻译的时候虎躯一震:

skin this cat ???

太残忍了吧。

我当时就觉得这个翻译肯定不太对,这肯定是一个小俚语。于是考证了一下,原来是这个意思:

免费送你一个英语小知识,不用客气。

第二个应该关注的回答排在最后:

这个哥们叫你看看《Java并发编程实战》的第 5.6 节的内容,里面有你要寻找的答案。

巧了,我手边就有这本书,于是我翻开看了一眼。

第 5.6 节的名称叫做"构建高效且可伸缩的结果缓存":

好家伙,我仔细一看这一节,发现这是宝贝呀。

你看书里面的示例代码:

.png)

不就和提问题的这个哥们的代码如出一辙吗?

都是从缓存中获取,拿不到再去构建。

不同的地方在于书上把 synchronize 加在了方法上。但是书上也说了,这是最差的解决方案,只是为了引出问题。

随后他借助了 ConcurrentHashMap、putIfAbsent 和 FutureTask 给出了一个相对较好的解决方案。

你可以看到完全是从另外一个角度去解决问题的,根本就没有在 synchronize 上纠缠,直接第二个方法就拿掉了 synchronize。

看完书上的方案后我才恍然大悟:好家伙,虽然前面给出的方案可以解决这个问题,但是总感觉怪怪的,又说不出来哪里怪。原来是死盯着 synchronize 不放,思路一开始就没打开啊。

书里面一共给出了四段代码,解决方案层层递进,具体是怎么写的,由于书上已经写的很清楚了,我就不赘述了,大家去翻翻书就行了。

没有书的直接在网上搜"构建高效且可伸缩的结果缓存"也能搜出原文。

我就指个路,看去吧。

本文已收录至个人博客,欢迎来玩:

https://www.whywhy.vip/

精读《vue-lit 源码》

Posted: 13 Feb 2022 05:09 PM PST

vue-lit 基于 lit-html + @vue/reactivity 仅用 70 行代码就给模版引擎实现了 Vue Composition API,用来开发 web component。

概述

<my-component></my-component>  <script type="module">   import {     defineComponent,     reactive,     html,     onMounted,     onUpdated,     onUnmounted   } from 'https://unpkg.com/@vue/lit'    defineComponent('my-component', () => {     const state = reactive({       text: 'hello',       show: true     })     const toggle = () => {       state.show = !state.show     }     const onInput = e => {       state.text = e.target.value     }      return () => html`       <button @click=${toggle}>toggle child</button>       <p>       ${state.text} <input value=${state.text} @input=${onInput}>       </p>       ${state.show ? html`<my-child msg=${state.text}></my-child>` : ``}     `   })    defineComponent('my-child', ['msg'], (props) => {     const state = reactive({ count: 0 })     const increase = () => {       state.count++     }      onMounted(() => {       console.log('child mounted')     })      onUpdated(() => {       console.log('child updated')     })      onUnmounted(() => {       console.log('child unmounted')     })      return () => html`       <p>${props.msg}</p>       <p>${state.count}</p>       <button @click=${increase}>increase</button>     `   }) </script>

上面定义了 my-componentmy-child 组件,并将 my-child 作为 my-component 的默认子元素。

import {   defineComponent,   reactive,   html,    onMounted,   onUpdated,   onUnmounted } from 'https://unpkg.com/@vue/lit'

defineComponent 定义 custom element,第一个参数是自定义 element 组件名,必须遵循原生 API customElements.define 对组件名的规范,组件名必须包含中划线。

reactive 属于 @vue/reactivity 提供的响应式 API,可以创建一个响应式对象,在渲染函数中调用时会自动进行依赖收集,这样在 Mutable 方式修改值时可以被捕获,并自动触发对应组件的重渲染。

htmllit-html 提供的模版函数,通过它可以用 Template strings 原生语法描述模版,是一个轻量模版引擎。

onMountedonUpdatedonUnmounted 是基于 web component lifecycle 创建的生命周期函数,可以监听组件创建、更新与销毁时机。

接下来看 defineComponent 的内容:

defineComponent('my-component', () => {   const state = reactive({     text: 'hello',     show: true   })   const toggle = () => {     state.show = !state.show   }   const onInput = e => {     state.text = e.target.value   }    return () => html`     <button @click=${toggle}>toggle child</button>     <p>     ${state.text} <input value=${state.text} @input=${onInput}>     </p>     ${state.show ? html`<my-child msg=${state.text}></my-child>` : ``}   ` })

借助模版引擎 lit-html 的能力,可以同时在模版中传递变量与函数,再借助 @vue/reactivity 能力,让变量变化时生成新的模版,更新组件 dom。

精读

阅读源码可以发现,vue-lit 巧妙的融合了三种技术方案,它们配合方式是:

  1. 使用 @vue/reactivity 创建响应式变量。
  2. 利用模版引擎 lit-html 创建使用了这些响应式变量的 HTML 实例。
  3. 利用 web component 渲染模版引擎生成的 HTML 实例,这样创建的组件具备隔离能力。

其中响应式能力与模版能力分别是 @vue/reactivitylit-html 这两个包提供的,我们只需要从源码中寻找剩下的两个功能:如何在修改值后触发模版刷新,以及如何构造生命周期函数的。

首先看如何在值修改后触发模版刷新。以下我把与重渲染相关代码摘出来了:

import {   effect } from 'https://unpkg.com/@vue/reactivity/dist/reactivity.esm-browser.js'  customElements.define(   name,   class extends HTMLElement {     constructor() {       super()       const template = factory.call(this, props)       const root = this.attachShadow({ mode: 'closed' })       effect(() => {         render(template(), root)       })     }   } )

可以清晰的看到,首先 customElements.define 创建一个原生 web component,并利用其 API 在初始化时创建一个 closed 节点,该节点对外部 API 调用关闭,即创建的是一个不会受外部干扰的 web component。

然后在 effect 回调函数内调用 html 函数,即在使用文档里返回的模版函数,由于这个模版函数中使用的变量都采用 reactive 定义,所以 effect 可以精准捕获到其变化,并在其变化后重新调用 effect 回调函数,实现了 "值变化后重渲染" 的功能。

然后看生命周期是如何实现的,由于生命周期贯穿整个实现流程,因此必须结合全量源码看,下面贴出全量核心代码,上面介绍过的部分可以忽略不看,只看生命周期的实现:

let currentInstance  export function defineComponent(name, propDefs, factory) {   if (typeof propDefs === 'function') {     factory = propDefs     propDefs = []   }    customElements.define(     name,     class extends HTMLElement {       constructor() {         super()         const props = (this._props = shallowReactive({}))         currentInstance = this         const template = factory.call(this, props)         currentInstance = null         this._bm && this._bm.forEach((cb) => cb())         const root = this.attachShadow({ mode: 'closed' })         let isMounted = false         effect(() => {           if (isMounted) {             this._bu && this._bu.forEach((cb) => cb())           }           render(template(), root)           if (isMounted) {             this._u && this._u.forEach((cb) => cb())           } else {             isMounted = true           }         })       }       connectedCallback() {         this._m && this._m.forEach((cb) => cb())       }       disconnectedCallback() {         this._um && this._um.forEach((cb) => cb())       }       attributeChangedCallback(name, oldValue, newValue) {         this._props[name] = newValue       }     }   ) }  function createLifecycleMethod(name) {   return (cb) => {     if (currentInstance) {       ;(currentInstance[name] || (currentInstance[name] = [])).push(cb)     }   } }  export const onBeforeMount = createLifecycleMethod('_bm') export const onMounted = createLifecycleMethod('_m') export const onBeforeUpdate = createLifecycleMethod('_bu') export const onUpdated = createLifecycleMethod('_u') export const onUnmounted = createLifecycleMethod('_um')

生命周期实现形如 this._bm && this._bm.forEach((cb) => cb()),之所以是循环,是因为比如 onMount(() => cb()) 可以注册多次,因此每个生命周期都可能注册多个回调函数,因此遍历将其依次执行。

而生命周期函数还有一个特点,即并不分组件实例,因此必须有一个 currentInstance 标记当前回调函数是在哪个组件实例注册的,而这个注册的同步过程就在 defineComponent 回调函数 factory 执行期间,因此才会有如下的代码:

currentInstance = this const template = factory.call(this, props) currentInstance = null

这样,我们就将 currentInstance 始终指向当前正在执行的组件实例,而所有生命周期函数都是在这个过程中执行的,因此当调用生命周期回调函数时,currentInstance 变量必定指向当前所在的组件实例

接下来为了方便,封装了 createLifecycleMethod 函数,在组件实例上挂载了一些形如 _bm_bu 的数组,比如 _bm 表示 beforeMount_bu 表示 beforeUpdate

接下来就是在对应位置调用对应函数了:

首先在 attachShadow 执行之前执行 _bm - onBeforeMount,因为这个过程确实是准备组件挂载的最后一步。

然后在 effect 中调用了两个生命周期,因为 effect 会在每次渲染时执行,所以还特意存储了 isMounted 标记是否为初始化渲染:

effect(() => {   if (isMounted) {     this._bu && this._bu.forEach((cb) => cb())   }   render(template(), root)   if (isMounted) {     this._u && this._u.forEach((cb) => cb())   } else {     isMounted = true   } })

这样就很容易看懂了,只有初始化渲染过后,从第二次渲染开始,在执行 render(该函数来自 lit-html 渲染模版引擎)之前调用 _bu - onBeforeUpdate,在执行了 render 函数后调用 _u - onUpdated

由于 render(template(), root) 根据 lit-html 的语法,会直接把 template() 返回的 HTML 元素挂载到 root 节点,而 root 就是这个 web component attachShadow 生成的 shadow dom 节点,因此这句话执行结束后渲染就完成了,所以 onBeforeUpdateonUpdated 一前一后。

最后几个生命周期函数都是利用 web component 原生 API 实现的:

connectedCallback() {   this._m && this._m.forEach((cb) => cb()) } disconnectedCallback() {   this._um && this._um.forEach((cb) => cb()) }

分别实现 mountunmount。这也说明了浏览器 API 分层的清晰之处,只提供创建和销毁的回调,而更新机制完全由业务代码实现,不管是 @vue/reactivityeffect 也好,还是 addEventListener 也好,都不关心,所以如果在这之上做完整的框架,需要自己根据实现 onUpdate 生命周期。

最后的最后,还利用 attributeChangedCallback 生命周期监听自定义组件 html attribute 的变化,然后将其直接映射到对 this._props[name] 的变化,这是为什么呢?

attributeChangedCallback(name, oldValue, newValue) {   this._props[name] = newValue }

看下面的代码片段就知道原因了:

const props = (this._props = shallowReactive({})) const template = factory.call(this, props) effect(() => {   render(template(), root) })

早在初始化时,就将 _props 创建为响应式变量,这样只要将其作为 lit-html 模版表达式的参数(对应 factory.call(this, props) 这段,而 factory 就是 defineComponent('my-child', ['msg'], (props) => { .. 的第三个参数),这样一来,只要这个参数变化了就会触发子组件的重渲染,因为这个 props 已经经过 Reactive 处理了。

总结

vue-lit 实现非常巧妙,学习他的源码可以同时了解一下几种概念:

  • reative。
  • web component。
  • string template。
  • 模版引擎的精简实现。
  • 生命周期。

以及如何将它们串起来,利用 70 行代码实现一个优雅的渲染引擎。

最后,用这种模式创建的 web component 引入的 runtime lib 在 gzip 后只有 6kb,但却能享受到现代化框架的响应式开发体验,如果你觉得这个 runtime 大小可以忽略不计,那这就是一个非常理想的创建可维护 web component 的 lib。

讨论地址是:精读《vue-lit 源码》· Issue #396 · dt-fe/weekly

如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。

关注 前端精读微信公众号

<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">

版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证

No comments:

Post a Comment