2025年1月

去年我写了一篇也是讲
开源商业化
的文章,
当时是月入 30 万,一年过去了,我们整整涨了 5 倍多
。本文理论结合实践,比较干货,希望对大家有帮助。

我们的现状,谁在给我们付钱

第一,开发者
,我们已经近 20 万用户了,而且随着
Sealos Devbox
的发布,活跃用户和付费用户飙增,广受用户好评,且用户已经形成了自发性的传播。其实云计算不是一个赚快钱的赛道,有那么多巨头在,而且需要长期积累,我们今天的成就是一个非常非常非常小的还不错的开始。

开发者是非常重要的群众基础,虽然开发者的付费能力有限 (特别在当前的中国),我们从刚开始就没直接去切 B 端用户,因为我们想做一款东西让真正在使用它的人舒服,效率提升而不是通过搞定企业的决策者,自顶向下做 toB 的生意。

我们要做一个大生意,品牌建立起来,同样是离不开开发者这个群体,所以 Sealos 能做成的牢牢的基石就是服务好开发者,做出让开发者认可的产品。另外,由于我自己是个十多年老码农,太理解开发者的痛苦了,而且我还很擅长把复杂的东西简单化,这让我们获得了非常多开发者认可。

所以很多开发者给我们充值十几二十块,这完全符合我的预期,
营收占比很小,但是潜在价值巨大

第二,中小 B
,第二个符合我预期的事就是,一旦开发者足够多了,就会逐渐有一些小 B 开始用我们的产品,这几乎是一个顺其自然的事,因为小 B 决策路径很短,开发者图便宜和省事就直接用我们的了,然后费用超过一定值时肯定就不想自己掏腰包了,就去找领导,领导一开始可能也有点担心,觉得稳不稳定会不会跑路什么的,然后一看哦 Sealos 开源做的还不错,那试试吧,一试不要紧,后面就离不开了,发现我们不仅好用而且支持还特别给力,几乎是一对一拉群支持,这也是早期吃螃蟹用户得到的额外好处,我们充当了他们的稳定性保障和运维团队了。我们团队又非常会通过技术手段收敛问题,实际支撑大部分问题都会在产品和技术层面解决掉。

这部分用户贡献了公有云收入的大头,里面不乏一些非常优秀的创业公司,如有
千万玩家的
开心自走棋
,匆匆雪工作室,joyjoy games,
优秀开源项目 Teable

等等,这样的公司我们
服务了一千多家
!未来他们成长我们就成长,而且这些公司也会吸引更多同类公司,他们深知使用我们之后基础设施几乎成本降低了 80% 这非常夸张,效率也不知道提升了多少倍,最重要是总算告别了恶心。

第三,大 B 用户
,令我意外的是大 B 来的如此之快,
我预期云这个东西不干个五年可能大 B 都看不上我们,但是事实是非常多大 B 主动找上我们,这和我们在开源社区的影响力分不开,毕竟
Sealos

FastGPT
都是非常出名的开源项目了

,再配合上企业自己的调研基本上很容易促成合作,甚至大部分面都没见就打款了。我大部分拜访的客户,但凡确实有匹配需求的,无一例外全部选了我们。我们服务了像零跑汽车,雪花啤酒,克诺尔,欧派家居,绿联,AO 史密斯,伊利集团,雅迪电动车,高驰等一众大 B 用户。

触客,安全感与信任

我们到目前为止几乎都没有销售人员
,我们的触客成本极低,大部分是客户主动找我们的,这如果不开源,没有品牌效应,没有知名度的影响几乎不可能。今天的影响力几乎都是社区自动产生的,如果不开源各种博主 up 主是不太可能主动免费给我们做宣传的。不是说必须得开源才会这样,而是同等条件下,开源更容易被传播。做好一个开源项目是技术公司品牌传播最快速的方式,难的是怎么做好一个开源项目。

“小公司挂了怎么办?” “至少我们还有源代码”,所以开源是可以给客户更多的安全感的。

信任基础,建立信任其实是非常困难的,但是一个优秀的开源项目,可能客户看到第一眼就倾向于去信任,我们去购买别的产品也一样,对于不熟悉的东西开始时一定是质疑的,如果不开源你需要花很大代价与客户建立信任。

我想打你,打不过你,我先开为敬

比如 Llama 要打 OpenAI,效果虽然差点,但我开源,一样可以分到一些流量。和你的竞争对手竞争时,他如果先发,咱就可以开源,如果能倒逼对手也开源那也不见得是坏事。

开源与免费

做开源大家最为忌讳的就是用户不付钱的矛盾,特别是害怕开源做的越优秀越稳定付钱的越少,这个确实不好解决,核心是得想清楚模式,想清楚谁在付费,想清楚场景。

很多开源产品选择了区分开源版与商业版,在开源版中有所保留。这不算是个高明的做法,因为本身要做出一款优秀开源软件就是需要使出全力的,如果还有所保留,项目本身就挺难成功
。此外对积累口碑也不是很好,大家只会觉得你以开源为幌子,引流而已。立场不同,公司又得生存,有时也是迫不得已。

所以我觉得或许可以整一个对商业化友好的开源协议,因为大部分大公司还是重视合规的,这样开源产品可以尽情开源,开发者长尾用户也能免费,项目会得到良性的发展。

Sealos 就无这个问题,也不去区分开源版和商业版
,原因有几块:

第一,
商业模式是云服务
,也就是大部分想用我们产品的人是懒得自己搭建的,而且自己搭建一定更贵。

第二,有部分学习的人会去搭建,这部分
个人居多,那免费也就免费了,没什么问题

第三,私有云偏大客户居多,他们要的是服务,是稳定性,是兜底能力,且 Sealos 能给他们带来的价值,或者节省的成本是付出的很多很多倍。大企业用我们保守估计每年都可以节省千万级的基础设施成本。还有就是采购我们比他们自己维护便宜且专业。决策者最基本的技能就是算 ROI,这些账都是能算过来的。

所以一旦选择了开源,那必然要接受一部分用户和一部分场景免费。靠服务赚回来,云服务和私有化支持服务都属于服务。

如何通过开源实现商业化

开源商业化是个门槛挺高的事,我们做过挺多还不错的开源项目,这里有一些思考希望对大家有帮助。

1. 挖掘需求

比较简单的一种发现需求的方式是发现自己的需求
,或者身边人,或者自己公司的需求,其实挺多的,让我们觉得挺麻烦的事都是需求,我随手都可以列出好多,比如:我现在处理发票挺麻烦,简历的分析挺麻烦等等。

第二件事去
搜这个需求有没有比较好的方案
,如果别人的方案还不错了,甚至有很多好方案了,那就不太需要你去做,比如笔记的需求,Notion 和 AFFINE 做的都很好,你没有信心做的比他们更好,那就不做这个。你发现竞品一些明显缺陷,那可以考虑做。或者发现一些好产品没开源,你做开源替代也可以。

第三件事是
评估这个需求的价值
,基本是
价值 = 体量 * 单价
,单价就是对每个使用者或者公司的价值,比如做个笔记软件大概评估一下用户可能平均原因付 5 刀/月。然后是分析受众,有些人确实发现了一些需求,但是太小众,而且这个小众群体原因为之付费也很低,这就不利于开源商业化。所以可以用这个去为需求评分,看值不值得做。这其实和创业一样,就是评估天花板在哪。这不需要太准,但是得有个大概判断:雷总说太阳打西边出来你能做多大。

2. 把项目做出来

项目早期特别是你还没啥影响力时基本就是靠自己,或者若干志同道合朋友,这确实是需要强执行力,大部分能成的项目都是能进入一种状态,就是你发现
有时间就想去写代码,不写浑身难受,我在写 Sealos 时就是这样,基本晚上 8 点到 12 点都在写,进入一种入定状态,也会感觉非常充实
。如果你没有这种感觉,那比较危险。也就是做这件事的时候不会感觉压力大,而是充实,快乐,且自豪就对了。

3. 运营,让别人知道你的项目

早期你是没有任何资源的,非常简单粗暴的运营方式:
写博客,然后各种群里 “恬不知耻” 的推广发消息
,大部分群对开源项目的推广不会那么反感,即便做的不好被人鄙视也不要害怕,虚心请教然后改进,Sealos 早期也不少被骂,即使到现在也还有零星的差评。

积累到 500~1000 star 左右基本会有一些自传播效应了,核心还是项目做的不错确实解决了一些需求。当然如果你视频做的不错的话效果会更好,做视频写博客写代码都一样要注重质量,一个精品顶 1000 个普通内容。

4. 商业模式

第四件事是想清楚模式,当然这个不是特别着急,等项目做到一定程度再去想商业模式也可以,比如 Sealos 早期就在卖安装包,后面才提供云服务。

当项目能赚到一点点钱的时候可以考虑全投出去,比如在开发方面去激励开源社区的开发者,在运营方面去投广告等。这样不断形成良性循环,我一直是觉得
商业化是有益开源项目发展的,如果没有商业化 Sealos 不可能发展这么快

开源项目创业

项目还可以,还能赚到钱,起点就挺不错的,可以考虑去好好写好商业计划去融资,这里又要推荐一下奇绩创坛了,是个非常理想主义的孵化器,陆奇博士确实是在为孵化科技企业努力做贡献的,而且创业者优先,谁都可以申请。这不是广告,而是真心强烈推荐。

聊融资有几个建议点:

  1. 一开始不用追求大机构或者高级别的人聊,因为在开始时很多基本问题其实你自己肯定都没全想清楚,多被挑战是好事,等自己聊明白了再去找你认为重要的机构和投资人聊。怎么接触他们很简单,所有的官网都可以投 BP,人家对你感兴趣自然会联系你。
  2. 所以一个好的 BP 也非常重要,我觉得
    不需要长篇大论,也不需要精美,而是需要把事情讲清楚
    ,一针见血,投资人看到你的 BP 瞬间感觉有了新的认知那大概率会对你感兴趣。
  3. 不要害怕被拒绝,不被拒绝个 100 次都不叫融资
    ,我们在天使轮的时候我一周聊了 50 多投资人,一天 8 个的强度,没一个愿意投的,此时一点都不用灰心,认真听他们的反馈并改进你的 BP,在这个过程中项目不能停,要不断完善,做出新的成绩。
  4. 做好最坏的打算,拿不到钱也得继续,办法总比困难多,
    项目唯一可能失败的原因就是创始人自己丧失信心
    ,我们永远都在资源匮乏时把事做成。

以上一步一步做完,那么恭喜你,游戏的第一关新手营你通关了,后面任重而道远,但这才有趣不是嘛。
踏上取经路比到达西天更重要
,我也刚出新手营,其他东西等以后积累更多经验再给大家分享,最后祝大家 2025 元旦快乐。

推荐一个轻量级的任务调度开源项目。

01 项目简介

Coravel是一个.NET开源任务调度库,只需简单代码、几乎零配置就可以实现多种功能柜,如任务调度、队列、缓存、事件广播和邮件发送等。该项目特点就是让这些通常复杂的功能变得易于访问和使用,同时提供简洁、直观的语法。

02 核心功能

1、任务/作业调度:
通过其流畅的代码内语法,让你能够轻松地在应用程序中设置和管理这些任务。

2、队列:
提供了一个开箱即用的队列系统,它使用内存作为后端来异步处理任务,从而不会阻塞用户的 HTTP 请求,改善了应用的性能和用户体验。

3、缓存:
为了提高应用程序的响应速度,Coravel 提供了一个简单易用的缓存 API。默认情况下,它使用内存缓存,但也支持数据库驱动(SQL Server、PostgreSQL),也可以自定义扩展缓存接口,以适应更复杂的缓存需求。

4、事件广播:
可以构建松耦合的应用程序组件,这有助于提高应用程序的可维护性和灵活性。

5、邮件发送:
简化了邮件发送过程,提供了内置的电子邮件友好的 Razor 模板、简单灵活的邮件 API,并且支持渲染电子邮件以进行视觉测试。此外,它还支持 SMTP、本地日志文件或自定义邮件器驱动程序。

03 使用示例

1、安装依赖库

dotnet tool install --global coravel-cli

2、任务调度

//启用
services.AddScheduler();

var provider = app.ApplicationServices;
provider.UseScheduler(scheduler =>
{
    scheduler.Schedule(
        () => Console.WriteLine("工作日每一分钟执行一次。")
    )
    .EveryMinute()
    .Weekday();
});

3、队列

IQueue _queue;

public HomeController(IQueue queue) {
    this._queue = queue;
}

//使用队列
this._queue.QueueAsyncTask(async() => {
    await Task.Delay(1000);
    Console.WriteLine("这是队列!");
 });

4、广播

var provider = app.ApplicationServices;
IEventRegistration registration = provider.ConfigureEvents();

//注册和监听
registration
  .Register<BlogPostCreated>()
  .Subscribe<TweetNewPost>()
    .Subscribe<NotifyEmailSubscribersOfNewPost>();

5、发送邮件

using Coravel.Mailer.Mail;
using App.Models;

namespace App.Mailables
{
    public class NewUserViewMailable : Mailable<UserModel>
    {
        private UserModel _user;

        public NewUserViewMailable(UserModel user) => this._user = user;

        public override void Build()
{
            this.To(this._user)
                .From("from@test.com")
                .View("~/Views/Mail/NewUser.cshtml", this._user);
        }
    }
}

04 项目地址

https://github.com/jamesmh/coravel

更多开源项目:
https://github.com/bianchenglequ/NetCodeTop

- End -

推荐阅读

推荐一个C#轻量级矢量图形库

.NET日志库:Serilog、NLog、Log4Net等十大开源日志库大盘点!

推荐5个.Net版本 Redis 客户端开源库

ImageSharp:高性能跨平台.NET开源图形库

盘点3个.Net热门HTTP开源库

2024即将过去,回顾这一年应该是人生中的重大转折之年,从服务11年公司退役,从青年进入了尴尬的中年,网上有很多声音抱怨“中年男人不如狗”,上有老下有小的年纪。幸好几年前遇到挫折看了一些哲学,接受一切发生的事务,好好享受低谷期,感谢曾老先生的智慧!

2024回顾

一、6月份从退役后,跟家里人申请了半年的时间,因为有几个意向项目一直跟踪,顺便将未来社区的公众号运营起来;

二、7-8月
物联网浏览器
增加了AI视觉识别相关的功能《
探讨使用智能AI在农业养殖中的风险预警与应用
》,后续几乎停滞了更新,主要有两个原因,一是谈了几个月的电力监测系统在10月收到通知被中止了,原本是打算用项目来继续优化产品的。二是驿站开业的前面2个月,每天累到倒头就睡,完全没有精力。好在博客园上认识的朋友提供了一些插件开发机会,后端是java前端使用html5开发的无人值守过磅称重系统《
物联网浏览器(IoTBrowser)-Java快速对接施耐德网络IO网关
》,这次主要是开发了几款新的地磅秤驱动,感谢园友的支持

三、9月份在小区开了一家菜鸟驿站,解决了一年多来快递丢失、找快递难的问题,主要是给未来社区寻找一个运营载体。经过几个月的摸索,驿站已经正常运转,驿站最大的好处不是赚多少钱,而是治好了多年的颈椎病,体重瘦了10KG;当然未来社区的样板也在同时期推广运营起来了,成功上线了几个小功能,比如快递查询、预约配送、会员打折等等。

2024年最大的收获是看到了未来的方向,通过菜鸟驿站认识了远方好物平台,远方好物让我再次燃起了对农业、食品安全的热情,完成了我多年前想做又没有实现愿望“为解决中国农产品低价滞销,解决小孩子吃的健康,解决有机农人农产品卖不出去”。

2025计划

一、2025年,借助远方好物平台的思路,将私域电商与未来社区进行结合,在每个小区寻找关心粮食与安全的人,共同服务更多城市居民,这是一个长期值得投入的方向,欢迎同频的兄弟们一起交流学习。

二、承接软件系统开发业务,尤其是物联网、工控系统方向的软硬件系统集成与开发。有下列场景欢迎与我联系

物联网浏览器(IoTBrowser)是用于开发人机界面(HMI)或数据采集与监督控制系统(SCADA) 的工具,使用HTML或Vue前端技术开发物联网终端用户界面,支持串口、RFID、电子秤等硬件协议,支持js控制关机、全屏等工控操作。详细介绍:
物联网浏览器(IoTBrowser)-简单介绍 - 木子清 - 博客园 (cnblogs.com)

项目开源地址:

https://gitee.com/yizhuqing/IoTBrowser

三、2025年变局元年,未来的路充满挑战与期待,希望未来的十年做点自己想做又不给社会带来麻烦的事情,将所学之术让生活更美好一点。

CountDownLatch是一个常用的共享锁,其功能相当于一个多线程环境下的倒数门闩。CountDownLatch可以指定一个计数值,在并发环境下由线程进行减一操作,当计数值变为0之后,被await方法阻塞的线程将会唤醒。通过CountDownLatch可以实现线程间的计数同步。

为什么说CountDownLatch是一种共享锁?因为CountDownLatch提供了一种“计数器”机制,允许N个线程在CountDownLatch的协调下同时运行,计数器归零才会触发阻塞的线程继续执行后续代码。

可能还是有人无法明白“共享”两个字,举个例子:如果计数器初始值是10,那开始的时候会有十个线程自动获取到共享锁,由于计数器的值是10,所以业务上如果有更多的线程想要获取锁,就要等待(主线程await);十个线程调用countDown方法之后锁才会释放,这时候阻塞的主线程才能获取到共享锁。

一、基本使用

CountDownLatch的使用步骤如下所示:

(1)创建倒数闩,初始化CountDownLatch时设置倒数的总次数,比如为100。

(2)等待线程调用倒数闩的await()方法阻塞自己,等待倒数闩的计数器数值为0(倒数线程全部执行结束)。

(3)倒数线程执行完,调用CountDownLatch.countDown()方法将计数器数值减一。

现在我们定义10个线程,十个线程都运行任务结束后,打印“Hello,World”。

import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;

/**
 * @author kdyzm
 * @date 2024/12/28
 */
@Slf4j
public class CountdownLatchDemo {

    private static final CountDownLatch countDownLatch = new CountDownLatch(10);

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 10; i++) {
            new Thread(new Task(), "" + i).start();
        }
        countDownLatch.await();
        log.info("Hello,World");

    }

    static class Task implements Runnable {
        @SneakyThrows
        @Override
        public void run() {
            log.info("我是线程【{}】,即将完成任务", Thread.currentThread().getName());
            TimeUnit.SECONDS.sleep(1);
            countDownLatch.countDown();
        }
    }
}

代码运行结果如下:

image-20241228145230060

CountDownLatch很神奇,竟然能同步多个线程的执行,让多个线程都执行完才进行下一步行动,那么它实现的原理是什么呢?

搂一眼CountDownLatch类:

image-20241230222815271

这代码可太熟悉了,和ReentrantLock非常相似啊。。。内部也是定义了Sync类继承了AQS类,大体上可以明白了,CountDownLatch类也是基于AQS类实现的。

CountDownLatch有两个重要的方法:await方法和countDown方法。

二、CountDownLatch实例的创建

CountDownLatch构造方法需要传递一个整数

public CountDownLatch(int count) {
    if (count < 0) throw new IllegalArgumentException("count < 0");
    this.sync = new Sync(count);
}

在构造方法中初始化了Sync类的实例。

private static final class Sync extends AbstractQueuedSynchronizer {
    private static final long serialVersionUID = 4982264981922014374L;

    Sync(int count) {
        //使用传递的count参数初始化state数值
        setState(count);
    }
    ......
}

可以看到,在Sync类的构造方法中,会使用传入的count参数初始化state参数。在ReetrantLock类中,state参数代表着重入锁的重入次数,在CountDownLatch中将其设置成count数值有什么意义呢?

实际上在CountDownLatch中state参数表示可以执行countDown方法的次数。

二、await方法

一般而言,await方法是先开始执行的方法,然后迅速进入阻塞状态,等待其它线程计数到0,就会触发唤醒,然后主线程执行await方法之后的代码。

public void await() throws InterruptedException {
    sync.acquireSharedInterruptibly(1);
}

await方法很简单,它的逻辑都委托给Sync类实例来实现了,但是实际上这里的acquireSharedInterruptibly方法调用是AQS的模板方法。

1、可中断获取共享锁:acquireSharedInterruptibly

public final void acquireSharedInterruptibly(int arg)
        throws InterruptedException {
    if (Thread.interrupted())
        throw new InterruptedException();
    //查看计数器是否归零
    if (tryAcquireShared(arg) < 0)
        //如果没归零,就开始执行可中断获取共享锁流程
        doAcquireSharedInterruptibly(arg);
}

这个方法非常简单,这个方法是一个可中断的方法,可中断意味着它可以抛出InterruptedException异常。所以上来它先检查了当前线程是否发生了中断,如果发生了中断,就立刻抛出InterruptedException异常。

接下来调用了tryAcquireShared方法查看计数器是否归零

2、查看计数器是否归零:tryAcquireShared

tryAcquireShared方法是AQS的钩子方法,被CountDownLatch实现,用于检查计数器是否变成了0。

protected int tryAcquireShared(int acquires) {
    return (getState() == 0) ? 1 : -1;
}

可以看到,传过来的acquires参数并没有被用到,实际上该方法就是检查了state参数是否变成了0。

这里其实有个疑问,根据ReentrantLock的经验,tryAcquireShared是尝试获取锁的方法,但是它仅仅只是判断了state的值是否为0,这是为何?

3、执行获取共享锁:doAcquireSharedInterruptibly

 private void doAcquireSharedInterruptibly(int arg)
        throws InterruptedException {
     //将节点标记为共享类型,并加入AQS同步队列。
    final Node node = addWaiter(Node.SHARED);
    boolean failed = true;
    try {
        for (;;) {
            //获取节点的前置节点
            final Node p = node.predecessor();
            //判断是否是头结点,是头结点就表示自己可以尝试获取锁了。
            if (p == head) {
                //尝试获取共享锁,实际上是检查了state参数是否为0
                int r = tryAcquireShared(arg);
                //r>=0的条件只有一个,那就是r为1,表示state值已经变成了0
                if (r >= 0) {
                    //执行获取锁成功的流程。
                    setHeadAndPropagate(node, r);
                    p.next = null; // help GC
                    failed = false;
                    return;
                }
            }
            //检查是否应该在失败后挂起。
            if (shouldParkAfterFailedAcquire(p, node) &&
                //挂起线程
                parkAndCheckInterrupt())
                //如果发生了中断,就抛出IE异常。
                throw new InterruptedException();
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

在这里,AQS队列的规则和ReentrantLock中AQS队列一样,头部节点表示已经获取锁的节点,一旦被唤醒,头部节点的后续节点就可以抢锁了。

CountDownLatch的抢锁逻辑比较简单,只是检查下state参数是否已经变成了0,变成了0,就表示其余线程都执行完了countDown方法,计数器已经变成了0,当前线程需要释放了;没变成0,就表示不是所有线程都执行了countDown方法,还不满足获取锁的条件。

不满足获取锁的条件的话,就开始判定是否需要挂起线程,执行
shouldParkAfterFailedAcquire
方法,该方法会被执行两次,然后执行
parkAndCheckInterrupt
方法,将当前主线程挂起。

主线程挂起之后,就开始等待其余线程执行执行countDown方法之后唤醒它。

4、获取锁成功之后:setHeadAndPropagate

执行到
setHeadAndPropagate
方法,就表示当前线程已经获取到了锁,执行到该方法的时候,计数器必定已经变成了0,parkAndCheckInterrupt()方法原本是被阻塞的现在已经被释放。

private void setHeadAndPropagate(Node node, int propagate) {
    Node h = head;
    setHead(node);
    //propagate值为1,满足if条件
    if (propagate > 0 || h == null || h.waitStatus < 0 ||
        (h = head) == null || h.waitStatus < 0) {
        Node s = node.next;
        //AQS队列中只有node一个等待节点,所以next必定为null,满足if条件
        if (s == null || s.isShared())
            //后续没有等待节点了,执行锁释放流程
            doReleaseShared();
    }
}

该方法中有两处if,有很多判断根本用不到,大概是给其它共享锁使用的。总之CountDownLatch的await方法执行到这里之后会执行doReleaseShared方法执行锁释放,因为后续已经没有节点了。

5、锁释放:doReleaseShared

await方法中刚获取到锁就开始执行锁释放了,这是因为AQS中等待队列中就一个节点,该节点后续没有了等待节点,就需要释放锁。

rivate void doReleaseShared() {
    for (;;) {
        Node h = head;
        //此时AQS队列就一个head节点,head == tail,不满足if条件
        if (h != null && h != tail) {
            int ws = h.waitStatus;
            if (ws == Node.SIGNAL) {
                if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
                    continue;            
                unparkSuccessor(h);
            }
            else if (ws == 0 &&
                     !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
                continue;               
        }
        //满足if条件,结束循环
        if (h == head)                  
            break;
    }
}

CountDownLatch的await方法中执行到doReleaseShared的时候是有附件条件的:AQS队列中只有一个头结点,没有等待节点了,所以第一个if条件没有满足条件,第二个if条件满足了,所以结束了循环。

doReleaseShared方法什么都没做。

这样await方法结束后,主线程继续执行await方法后的代码。

三、countDown方法

通常情况下,await方法会先执行,并在doAcquireSharedInterruptibly方法中的parkAndCheckInterrupt处阻塞,countDown方法在其它线程中被调用,并在计数器归零时唤醒被阻塞在await方法的线程。

public void countDown() {
    sync.releaseShared(1);
}

countDown方法依然调用了sync的方法:releaseShared方法是AQS的模板方法。

1、释放共享锁:releaseShared

public final boolean releaseShared(int arg) {
    //尝试释放锁,计数器减一
    if (tryReleaseShared(arg)) {
        //释放共享锁
        doReleaseShared();
        return true;
    }
    return false;
}

releaseShared是AQS的模板方法,该方法会调用钩子方法tryReleaseShared方法执行计数器减一,如果满足了释放共享锁的条件,就执行doRelease方法释放共享锁。

比如计数器初始值是10,则前九个线程调用countDown方法都不会满足if条件,第十个线程调用countDown方法时,调用tryReleaseShared方法之后会满足if条件,然后执行doReleaseShared方法。

2、计数器减一:tryReleaseShared

tryReleaseShared是AQS的钩子方法,在CountDownLatch中,被Sync实现了该方法。

protected boolean tryReleaseShared(int releases) {
    //循环防止之后的CAS失败
    for (;;) {
        int c = getState();
        //如果计数器的值已经是0,就返回失败,这里是为了防止出现超出计数器初始值的线程数调用countDown方法
        if (c == 0)
            return false;
        //计数器减一
        int nextc = c-1;
        //CAS设置state值
        if (compareAndSetState(c, nextc))
            /**
             * 如果设置成功了,再次判断计数器是否为0:
             *   如果是0表示当前操作使得计数器归零,应当触发释放共享锁的操作
             *	 如果不是0表示计数器还未归零,不到释放共享锁的时机
             */
            return nextc == 0;
    }
}

由于多个线程可能会同时执行countDown方法,可能会并行修改state值,所以该方法使用了for循环+CAS的方式保证了线程安全性。

需要注意的唯一一点就是

 if (c == 0)
	return false;

这个判断语句,该判断语句用于防止出现超出计数器初始值的线程数调用countDown方法,当超出计数器初始值的线程调用countDown方法,会因为计数器已经归零而导致tryReleaseShared方法返回false。

3、释放锁:doReleaseShared

在众多执行countDown方法的线程中,只有将计数器置为零的那个线程有执行doReleaseShared方法的机会。

doReleaseShared方法在之前await方法中已经讲过一部分,在await方法中如果当前节点是最后一个节点,那最后将调用doRelease方法释放锁。为什么这里会释放锁?因为执行countDown方法的线程都是获取到锁的线程,执行完毕之后要调用countDown方法释放锁,当然还是那句话,只有将计数器置为零的那个线程有执行doReleaseShared方法的机会。

和await方法中调用doReleaseShared方法什么都没做不一样,在countDown方法中调用doReleaseShared执行逻辑就变得不一样了。一般来说,由于await方法会先于countDown方法执行,所以在执行countDown方法的时候,AQS队列中会有两个节点,一个是头结点(AQS初始化创建的虚拟节点),一个是等待队列中的主线程节点。

private void doReleaseShared() {
    for (;;) {
        //获取头结点,并暂存到h变量
        Node h = head;
        //当前AQS队列中有两个节点,满足if条件
        if (h != null && h != tail) {
            //获取到头结点状态
            int ws = h.waitStatus;
            //由于await方法已经将头结点状态更改成了SIGNAL,所以这里会满足if条件
            if (ws == Node.SIGNAL) {
                //将头结点状态改为0
                if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
                    continue;   
                //唤醒后继结点
                unparkSuccessor(h);
            }
            else if (ws == 0 &&
                     !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
                continue;                // loop on failed CAS
        }
        //一般情况下,执行countDown方法的线程执行速度比较快,会满足该if条件并结束循环
        if (h == head)               
            break;
    }
}

可以看到将计数器归零的countDown方法调用的线程,最后会在doReleaseShared方法中调用unparkSuccessor方法唤醒后继线程。后继线程实际上就是await方法被阻塞的主线程,它将会在doAcquireSharedInterruptibly方法中的parkAndCheckInterrupt方法处被唤醒后继续执行后续代码。

四、扩展问题

1、countDown方法先于await方法执行

一般情况下await方法会先执行,执行后线程被挂起阻塞;countDown方法后执行,最后将计数器归零的线程负责将调用await方法被挂起的线程唤醒。想一个问题:如果cuontDown方法先都执行完了,最后再执行await方法,会发生什么呢?

答案是:什么都不会发生,因为这就是正确的逻辑啊。await方法和countDown方法均为此做了防范措施。

countDown方法:

最后将计数器清零的线程会将被阻塞的线程唤醒,但是如果说await方法没有被调用,则AQS队列中就没有需要被唤醒的线程,doReleaseShared方法会如何做呢?

private void doReleaseShared() {
    for (;;) {
        //由于AQS队列为空,所以head为null
        Node h = head;
        //head为null,不满足if条件
        if (h != null && h != tail) {
            int ws = h.waitStatus;
            if (ws == Node.SIGNAL) {
                if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
                    continue;           
                unparkSuccessor(h);
            }
            else if (ws == 0 &&
                     !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
                continue;                
        }
        //h == head == null,满足if条件,结束循环
        if (h == head)                  
            break;
    }
}

doReleaseShared方法此时什么都没做,由于没有满足触发条件,因此都没有机会执行
unparkSuccessor(h);
唤醒后继节点。

await方法:

await方法中调用了
acquireShardInterruptibly
方法,该方法在执行的过程中会先判定当前计数器的值,如果是0,就不会再继续执行
doAcquireSharedInterruptibly
方法,避免了线程被挂起无人唤醒的尴尬局面。

public final void acquireSharedInterruptibly(int arg)
        throws InterruptedException {
    if (Thread.interrupted())
        throw new InterruptedException();
    //由于计数器的值已经归零,所以这里没有满足if条件
    if (tryAcquireShared(arg) < 0)
        doAcquireSharedInterruptibly(arg);
}

2、超出计数器初始值的线程数执行countDown方法

如下代码所示

@Slf4j
public class CountdownLatchDemo {

    //计数器初始值是1
    private static final CountDownLatch countDownLatch = new CountDownLatch(1);

    public static void main(String[] args) throws InterruptedException {
        //10个线程执行了countDown方法
        for (int i = 0; i < 10; i++) {
            new Thread(new Task(), "" + i).start();
        }
        countDownLatch.await();
        log.info("Hello,World");

    }

    static class Task implements Runnable {
        @SneakyThrows
        @Override
        public void run() {
            log.info("我是线程【{}】,即将完成任务", Thread.currentThread().getName());
            //等待一秒钟模拟业务执行,确保await方法先执行
            TimeUnit.SECONDS.sleep(1);
            countDownLatch.countDown();
        }
    }
}

CountDownLatch对多余执行countDown方法的线程的处理策略就是:忽略它。看看countDown方法中调用的releaseShared方法

//该方法为AQS的模板方法
public final boolean releaseShared(int arg) {
    //tryReleaseShared方法是关键
    if (tryReleaseShared(arg)) {
        doReleaseShared();
        return true;
    }
    return false;
}

//该方法文为CountDownLatch中实现AQS的钩子方法
protected boolean tryReleaseShared(int releases) {
    for (;;) {
        int c = getState();
        //如果计数器值已经变成了0,就直接返回false,防止超量的线程执行countDown方法。
        if (c == 0)
            return false;
        int nextc = c-1;
        if (compareAndSetState(c, nextc))
            return nextc == 0;
    }
}

releaseShared方法仅仅是通过判断一次

 if (c == 0)
    return false;

就解决了超量线程调用countDown方法的问题。

3、CountDownLatch使用进阶

本篇文章通篇均是以以下代码示例分析的:

import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;

/**
 * @author kdyzm
 * @date 2024/12/28
 */
@Slf4j
public class CountdownLatchDemo {

    private static final CountDownLatch countDownLatch = new CountDownLatch(10);

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 10; i++) {
            new Thread(new Task(), "" + i).start();
        }
        countDownLatch.await();
        log.info("Hello,World");

    }

    static class Task implements Runnable {
        @SneakyThrows
        @Override
        public void run() {
            log.info("我是线程【{}】,即将完成任务", Thread.currentThread().getName());
            TimeUnit.SECONDS.sleep(1);
            countDownLatch.countDown();
        }
    }
}

这种CountDownLatch的使用方式比较简单,实际上CountDownLatch还有另外一种经典的使用方式,其源码示例如下所示

import java.util.concurrent.CountDownLatch;

class Driver { // ...
    void main() throws InterruptedException {
        CountDownLatch startSignal = new CountDownLatch(1);
        CountDownLatch doneSignal = new CountDownLatch(N);

        for (int i = 0; i < N; ++i) // create and start threads
            new Thread(new Worker(startSignal, doneSignal)).start();//线程刚开始执行全部进入阻塞状态
        doSomethingElse();            // 先执行一些动作
        startSignal.countDown();      // 让所有阻塞的线程开始执行
        doSomethingElse();
        doneSignal.await();           // 等待所有线程执行完毕
    }
}

class Worker implements Runnable {
    private final CountDownLatch startSignal;
    private final CountDownLatch doneSignal;

    Worker(CountDownLatch startSignal, CountDownLatch doneSignal) {
        this.startSignal = startSignal;
        this.doneSignal = doneSignal;
    }

    public void run() {
        try {
            startSignal.await();
            doWork();
            doneSignal.countDown();
        } catch (InterruptedException ex) {
        } // return;
    }

    void doWork() { ...}
}

这种模式巧妙的使用了双CountDownLatch实例精准控制线程的开始运行时间,并在开始执行前、执行中间、执行之后插入一些自定义的方法执行。实际上使用了CountDownLatch的两种使用模式:

模式一:N次countDown方法调用,一次await方法调用
:这种模式用于等待所有线程执行完毕之后再执行await方法之后的代码

模式二:N次await方法调用,一次countDown方法调用
:这种模式用于需要在所有线程开始执行前插入一些自定义操作。

本篇文章一直围绕模式一分析,模式二一直没有提及,因为模式二用的实在太少,在实际开发中没见到过。。。

现在将模式二的核心代码提炼出来,看看到底它做了什么事情:

import lombok.extern.slf4j.Slf4j;

import java.util.concurrent.CountDownLatch;

@Slf4j
public class Driver {
    public static void main(String[] args) throws InterruptedException {
        CountDownLatch startSignal = new CountDownLatch(1);
        for (int i = 0; i < 10; ++i) {
            new Thread(new Worker(startSignal)).start();
        }
        log.info("在所有线程执行前先执行一些任务");
        startSignal.countDown();
    }
}

@Slf4j
class Worker implements Runnable {
    private final CountDownLatch startSignal;

    Worker(CountDownLatch startSignal) {
        this.startSignal = startSignal;
    }

    @Override
    public void run() {
        try {
            startSignal.await();
            doWork();
        } catch (InterruptedException ex) {
            log.error("", ex);
        }
    }

    void doWork() {
        log.info("正在执行任务。。。");
    }
}

我们分析下这个过程,由于10个线程运行时上来就调用了await方法,所以它们会在AQS队列中排队,其数据结构如下所示

image-20250101223924205

此时AQS队列中有11个节点,第一个头部节点是队列初始化的时候创建的虚拟节点,剩余十个节点,前9个节点状态都是SIGNAL(-1),因为它们在抢锁失败后会调用shouldParkAfterFailedAcquire方法,该方法会将当前节点的前置节点修改成SIGNAL(-1),最后一个节点入队后默认状态是0,由于没有后续节点入队了,所以其状态没有变成-1,而是0。

接下来主线程调用了countDown方法,由于计数器的初始值是1,所以CountDownLatch方法一次调用就会触发唤醒后继结点。接着,head节点后的节点将会在
parkAndCheckInterrupt
方法处继续执行后续代码:

private void doAcquireSharedInterruptibly(int arg)
        throws InterruptedException {
     //将节点标记为共享类型,并加入AQS同步队列。
    final Node node = addWaiter(Node.SHARED);
    boolean failed = true;
    try {
        for (;;) {
            //获取节点的前置节点
            final Node p = node.predecessor();
            //判断是否是头结点,是头结点就表示自己可以尝试获取锁了。
            if (p == head) {
                //尝试获取共享锁,实际上是检查了state参数是否为0
                int r = tryAcquireShared(arg);
                //r>=0的条件只有一个,那就是r为1,表示state值已经变成了0
                if (r >= 0) {
                    //执行获取锁成功的流程。
                    setHeadAndPropagate(node, r);
                    p.next = null; // help GC
                    failed = false;
                    return;
                }
            }
            //检查是否应该在失败后挂起。
            if (shouldParkAfterFailedAcquire(p, node) &&
                //挂起线程
                parkAndCheckInterrupt())
                //如果发生了中断,就抛出IE异常。
                throw new InterruptedException();
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

在该方法中,会执行
setHeadAndPropagate
方法:

private void setHeadAndPropagate(Node node, int propagate) {
    Node h = head;
    setHead(node);
    //propagate值为1,满足if条件
    if (propagate > 0 || h == null || h.waitStatus < 0 ||
        (h = head) == null || h.waitStatus < 0) {
        Node s = node.next;
        //此时AQS队列中有很多节点,节点类型都是SHARED类型,所以依然满足if条件
        if (s == null || s.isShared())
            //执行锁释放,并唤醒后续节点
            doReleaseShared();
    }
}

接着执行doReleaseShared方法:在N次countDown,一次await模式中,该方法在await方法中的调用什么都没做就结束了循环;在N次await调用,一次countDown调用的模式中,await方法调用将唤醒后继节点

private void doReleaseShared() {
    for (;;) {
        //head节点已经变成当前执行线程节点
        Node h = head;
        //满足if条件
        if (h != null && h != tail) {
            //当前节点状态是SIGNAL
            int ws = h.waitStatus;
            //满足if条件
            if (ws == Node.SIGNAL) {
                //唤醒后继结点前,先将头部节点状态更改为初始状态0
                if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
                    continue;            // loop to recheck cases
                //唤醒后继结点
                unparkSuccessor(h);
            }
            else if (ws == 0 &&
                     !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
                continue;                // loop on failed CAS
        }
        if (h == head)                   // loop if head changed
            break;
    }
}

可以看到,AQS队列中的节点一个一个唤醒后继结点,然后去做自己的事情,直到最后一个节点;最后一个节点由于不满足
h != tail
的条件,会什么都不做就结束循环。

最后考虑一个问题,如果countDown方法先被执行了,那await方法执行的时候会怎样?

答案是什么事情都不会发生,因为await方法执行前需要先判断计数器的值,如果等于0,就不会有后续的获取锁、释放锁流程了。

public final void acquireSharedInterruptibly(int arg)
        throws InterruptedException {
    if (Thread.interrupted())
        throw new InterruptedException();
    //countDown计数器为0的时候不满足该if条件。
    if (tryAcquireShared(arg) < 0)
        doAcquireSharedInterruptibly(arg);
}

总结一下,ReentrantLock中lock方法和unlock方法中间是临界区代码,临界区代码是抢锁线程依次轮流进入执行的,所以ReetrantLock是“独占锁”;而在CountDownLatchd的“N次await方法调用,一次countDown方法调用”的模式中,中,所有线程排队进入AQS队列之后,一旦被唤醒,被唤醒的节点还会自动唤醒后边节点,然后各自做各自的事情,所有线程都是并行运行的,所以CountDownLatch是“共享锁”。



最后的最后,欢迎关注我的个人博客呀:

https://blog.kdyzm.cn


END.

如题,有客户咨询这个问题:Exadata X6支持的最新image和19c数据库版本?
直观感觉,看到X6这个型号就觉得是很老的机器了,毕竟现在最新都X10M了。

首先,去查MOS文档:

  • Exadata Database Machine and Exadata Storage Server Supported Versions (Doc ID 888828.1)


Exadata System Software Requirements for Exadata Database Machine Hardware
章节可以看到X6 支持的 image版本:

  • Exadata 12.1.2.3.1 through 24.1
    这也就是说支持最新的image到了24.1这个版本,而这个版本的image,连最新的Oracle 23ai数据库都能装!

23ai Long Term Release => Supported: Exadata 24.1 or higher
Database 23ai requires storage server and database server Exadata version to be 24.1 or higher, and fabric switch version to be that which is supplied with Exadata 24.1 or higher. Full Exadata offload functionality for all Database 23ai features is available starting in Exadata 24.1.

客户没有23ai需求,只关注19c的情况,而19c需要的image版本,只要在19.1.2或以上就OK,推荐23或24版本。

19c Long Term Release (Recommended) => Supported: Exadata 19.1.2.0.0 or higher
Exadata 24.1 requires ACFS Patch 36114443 in Grid Infrastructure 19c home, which is included in 19.23.
Database 19c requires storage server and database server Exadata version to be 19.1.2.0.0 or higher, and InfiniBand switch version to be 2.2.11-2 or higher (that which is supplied with Exadata 19.1.2.0.0). It is not supported to use lower versions on storage servers, database servers, or InfiniBand switches.

而数据库19c具体最新的版本,是取决于当前19c最新的RU的,目前还是19.25,是去年10月份发布的,按照惯例,预计这个月会推出更新的19.26:

参考MOS文档:

  • Assistant: Download Reference for Oracle Database/GI Update, Revision, PSU, SPU(CPU), Bundle Patches, Patchsets and Base Releases (Doc ID 2118136.2)

总结下,X6支持的image版本:Exadata 12.1.2.3.1 through 24.1,安装新版本的image,不但能支持19c数据库最新的RU版本,还支持最新的23ai数据库。
如果你还想知道其他Exadata型号的image版本和数据库版本支持情况,都可以结合查询MOS文档888828.1、2118136.2获取到官方说明。