Serverless时代Rust即将称王,详解C、Java、Go、Rust高并发背后的技术?

简介: 笔者在上周参加阿里云开发者大会时,特别注意到一个现象就是Serverless这个概念被反复提及,其受关注程度提升明显,笔者仔细看了一下,Serverless的核心理念就是函数式计算,开发者不需要再关注具体的模块,云上部署的粒度变成了程序函数,自动伸缩、扩容等工作完全由云服务负责,能够想象Serverless必将在未来引领时代潮流。

笔者在上周参加阿里云开发者大会时,特别注意到一个现象就是Serverless这个概念被反复提及,其受关注程度提升明显,笔者仔细看了一下,Serverless的核心理念就是函数式计算,开发者不需要再关注具体的模块,云上部署的粒度变成了程序函数,自动伸缩、扩容等工作完全由云服务负责,能够想象Serverless必将在未来引领时代潮流。

Serverless Computing,即”无服务器计算”,其实这一概念在刚刚提出的时候并没有获得太多的关注,直到2014年AWS Lambda这一里程碑式的产品出现。Serverless算是正式走进了云计算的舞台。2018年5月,Google在KubeCon+CloudNative 2018期间开源了gVisor容器沙箱运行时并分享了它的设计理念和原则。随后2018年的Google Next大会上Google推出了自己的 Google Serverless平台 —— gVisor。同年AWS又放了颗大炮仗-Firecracker,这是一款基于Rust语言编写的安全沙箱基础组件,用于函数计算服务Lambda和托管的容器服务。而值得注意的是Google也并没有死守自己一手缔造的Go语言平台,而是选择了Go与Rust的模式,据说Google在Rust方面也开始招兵买马,也要用Rust重写之前基于Go编写的Serverless平台。

笔者写本文的初衷,其实就是要回答为什么在这个高并发大行其道的时代,以性能著称的C语言和以安全高效闻名的Java都不香了呢?

高并发模式初探

在这个高并发时代最重要的设计模式无疑是生产者、消费者模式,比如著名的消息队列kafka其实就是一个生产者消费者模式的典型实现。其实生产者消费者问题,也就是有限缓冲问题,可以用以下场景进行简要描述,生产者生成一定量的产品放到库房,并不断重复此过程;与此同时,消费者也在缓冲区消耗这些数据,但由于库房大小有限,所以生产者和消费者之间步调协调,生产者不会在库房满的情况放入端口,消费者也不会在库房空时消耗数据。详见下图:

image.png

而如果在生产者与消费者之间完美协调并保持高效,这就是高并发要解决的本质问题。

 C语言的高并发案例

    笔者在前文《这位创造了Github冠军项目的老男人,堪称10倍程序员本尊》曾经介绍过TDEngine的相关代码,其中Sheduler模块的相关调度算法就使用了生产、消费者模式进行消息传递功能的实现,也就是有多个生产者(producer)生成并不断向队列中传递消息,也有多个消费者(consumer)不断从队列中取消息。

后面我们也会说明类型功能在Go、Java等高级语言中类似的功能已经被封装好了,但是在C语言中你就必须要用好互斥体( mutex)和信号量(semaphore)并协调他们之间的关系。由于C语言的实现是最复杂的,先来看结构体设计和他的注释:


  

  再来看Shceduler初始化函数,这里需要特别说明的是,两个信号量的创建,其中emptySem是队列的可写状态,初始化时其值为queueSize,即初始时队列可写,可接受消息长度为队列长度,fullSem是队列的可读状态,初始化时其值为0,即初始时队列不可读。具体代码及我的注释如下:


void *taosInitScheduler(int queueSize, int numOfThreads, char *label) {

 pthread_attr_t attr;

 SSchedQueue *  pSched = (SSchedQueue *)malloc(sizeof(SSchedQueue));

 

 memset(pSched, 0, sizeof(SSchedQueue));

 pSched->queueSize = queueSize;

 pSched->numOfThreads = numOfThreads;

 strcpy(pSched->label, label);

 

 if (pthread_mutex_init(&pSched->queueMutex, NULL) < 0) {

   pError("init %s:queueMutex failed, reason:%s", pSched->label, strerror(errno));

   goto _error;

 }

  //emptySem是队列的可写状态,初始化时其值为queueSize,即初始时队列可写,可接受消息长度为队列长度。

 if (sem_init(&pSched->emptySem, 0, (unsigned int)pSched->queueSize) != 0) {

   pError("init %s:empty semaphore failed, reason:%s", pSched->label, strerror(errno));

   goto _error;

 }

 //fullSem是队列的可读状态,初始化时其值为0,即初始时队列不可读

 if (sem_init(&pSched->fullSem, 0, 0) != 0) {

   pError("init %s:full semaphore failed, reason:%s", pSched->label, strerror(errno));

   goto _error;

 }

 

 if ((pSched->queue = (SSchedMsg *)malloc((size_t)pSched->queueSize * sizeof(SSchedMsg))) == NULL) {

   pError("%s: no enough memory for queue, reason:%s", pSched->label, strerror(errno));

   goto _error;

 }

 

 memset(pSched->queue, 0, (size_t)pSched->queueSize * sizeof(SSchedMsg));

 pSched->fullSlot = 0;//实始化时队列为空,故队头和队尾的位置都是0

 pSched->emptySlot = 0;//实始化时队列为空,故队头和队尾的位置都是0

 

 pSched->qthread = malloc(sizeof(pthread_t) * (size_t)pSched->numOfThreads);

 

 pthread_attr_init(&attr);

 pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_JOINABLE);

 

 for (int i = 0; i < pSched->numOfThreads; ++i) {

   if (pthread_create(pSched->qthread + i, &attr, taosProcessSchedQueue, (void *)pSched) != 0) {

     pError("%s: failed to create rpc thread, reason:%s", pSched->label, strerror(errno));

     goto _error;

   }

 }

 

 pTrace("%s scheduler is initialized, numOfThreads:%d", pSched->label, pSched->numOfThreads);

 

 return (void *)pSched;

 

_error:

 taosCleanUpScheduler(pSched);

 return NULL;

}

 

再来看读消息的taosProcessSchedQueue函数这其实是消费者一方的实现,这个函数的主要逻辑是

1.使用无限循环,只要队列可读即sem_wait(&pSched->fullSem)不再阻塞就继续向下处理
2.在操作msg前,加入互斥体防止msg被误用。
3.读操作完毕后修改fullSlot的值,注意这为避免fullSlot溢出,需要对于queueSize取余。同时退出互斥体。
4.对emptySem进行post操作,即把emptySem的值加1,如emptySem原值为5,读取一个消息后,emptySem的值为6,即可写状态,且能接受的消息数量为6

具体代码及注释如下:

void *taosProcessSchedQueue(void *param) {

 SSchedMsg    msg;

 SSchedQueue *pSched = (SSchedQueue *)param;

 //注意这里是个无限循环,只要队列可读即sem_wait(&pSched->fullSem)不再阻塞就继续处理

 while (1) {

   if (sem_wait(&pSched->fullSem) != 0) {

     pError("wait %s fullSem failed, errno:%d, reason:%s", pSched->label, errno, strerror(errno));

     if (errno == EINTR) {

       /* sem_wait is interrupted by interrupt, ignore and continue */

       continue;

     }

   }

    //加入互斥体防止msg被误用。

   if (pthread_mutex_lock(&pSched->queueMutex) != 0)

     pError("lock %s queueMutex failed, reason:%s", pSched->label, strerror(errno));

 

   msg = pSched->queue[pSched->fullSlot];

   memset(pSched->queue + pSched->fullSlot, 0, sizeof(SSchedMsg));

   //读取完毕修改fullSlot的值,注意这为避免fullSlot溢出,需要对于queueSize取余。

   pSched->fullSlot = (pSched->fullSlot + 1) % pSched->queueSize;

    //读取完毕修改退出互斥体

   if (pthread_mutex_unlock(&pSched->queueMutex) != 0)

     pError("unlock %s queueMutex failed, reason:%s\n", pSched->label, strerror(errno));

    //读取完毕对emptySem进行post操作,即把emptySem的值加1,如emptySem原值为5,读取一个消息后,emptySem的值为6,即可写状态,且能接受的消息数量为6

   if (sem_post(&pSched->emptySem) != 0)

     pError("post %s emptySem failed, reason:%s\n", pSched->label, strerror(errno));

 

   if (msg.fp)

     (*(msg.fp))(&msg);

   else if (msg.tfp)

     (*(msg.tfp))(msg.ahandle, msg.thandle);

 }

}

 

最后写消息的taosScheduleTask函数也就是生产的实现,其基本逻辑是

1.写队列前先对emptySem进行减1操作,如emptySem原值为1,那么减1后为0,也就是队列已满,必须在读取消息后,即emptySem进行post操作后,队列才能进行可写状态。
2.加入互斥体防止msg被误操作,写入完成后退出互斥体
3.写队列完成后对fullSem进行加1操作,如fullSem原值为0,那么加1后为1,也就是队列可读,咱们上面介绍的读取taosProcessSchedQueue中sem_wait(&pSched->fullSem)不再阻塞就继续向下。

int taosScheduleTask(void *qhandle, SSchedMsg *pMsg) {

 SSchedQueue *pSched = (SSchedQueue *)qhandle;

 if (pSched == NULL) {

   pError("sched is not ready, msg:%p is dropped", pMsg);

   return 0;

 }

 //在写队列前先对emptySem进行减1操作,如emptySem原值为1,那么减1后为0,也就是队列已满,必须在读取消息后,即emptySem进行post操作后,队列才能进行可写状态。

 if (sem_wait(&pSched->emptySem) != 0) pError("wait %s emptySem failed, reason:%s", pSched->label, strerror(errno));//加入互斥体防止msg被误操作

 if (pthread_mutex_lock(&pSched->queueMutex) != 0)

   pError("lock %s queueMutex failed, reason:%s", pSched->label, strerror(errno));

 

 pSched->queue[pSched->emptySlot] = *pMsg;

 pSched->emptySlot = (pSched->emptySlot + 1) % pSched->queueSize;

 

 if (pthread_mutex_unlock(&pSched->queueMutex) != 0)

   pError("unlock %s queueMutex failed, reason:%s", pSched->label, strerror(errno));

 //在写队列前先对fullSem进行加1操作,如fullSem原值为0,那么加1后为1,也就是队列可读,咱们上面介绍的读取函数可以进行处理。

 if (sem_post(&pSched->fullSem) != 0) pError("post %s fullSem failed, reason:%s", pSched->label, strerror(errno));

 

 return 0;

}

 

Java的高并发实现

从并发模型来看,Go和Rust都有channel这个概念,也都是通过Channel来实现线(协)程间的同步,由于channel带有读写状态且保证数据顺序,而且channel的封装程度和效率明显可以做的更高,因此Go和Rust官方都会建议使用channel(通信)来共享内存,而不是使用共享内存来通信。

为了让帮助大家找到区别,我们先以Java为例来,看一下没有channel的高级语言Java,生产者消费者该如何实现,代码及注释如下:

public class Storage {

 

   // 仓库最大存储量

   private final int MAX_SIZE = 10;

   // 仓库存储的载体

   private LinkedList<Object> list = new LinkedList<Object>();

   // 锁

   private final Lock lock = new ReentrantLock();

   // 仓库满的信号量

   private final Condition full = lock.newCondition();

   // 仓库空的信号量

   private final Condition empty = lock.newCondition();

 

   public void produce()

   {

       // 获得锁

       lock.lock();

       while (list.size() + 1 > MAX_SIZE) {

           System.out.println("【生产者" + Thread.currentThread().getName()

            + "】仓库已满");

           try {

               full.await();

           } catch (InterruptedException e) {

               e.printStackTrace();

           }

       }

       list.add(new Object());

       System.out.println("【生产者" + Thread.currentThread().getName()

+ "】生产一个产品,现库存" + list.size());

 

       empty.signalAll();

       lock.unlock();

   }

 

   public void consume()

   {

       // 获得锁

       lock.lock();

       while (list.size() == 0) {

           System.out.println("【消费者" + Thread.currentThread().getName()

            + "】仓库为空");

           try {

               empty.await();

           } catch (InterruptedException e) {

               e.printStackTrace();

           }

       }

       list.remove();

       System.out.println("【消费者" + Thread.currentThread().getName()

        + "】消费一个产品,现库存" + list.size());

 

       full.signalAll();

       lock.unlock();

   }

}

 

 

在Java、C#这种面向对象,但是没有channel语言中,生产者、消费者模式至少要借助一个lock和两个信号量共同完成。其中锁的作用是保证同是时间,仓库中只有一个用户进行数据的修改,而还需要表示仓库满的信号量,一旦达到仓库满的情况则将此信号量置为阻塞状态,从而阻止其它生产者再向仓库运商品了,反之仓库空的信号量也是一样,一旦仓库空了,也要阻其它消费者再前来消费了。

Go的高并发实现

我们刚刚也介绍过了Go语言中官方推荐使用channel来实现协程间通信,所以不需要再添加lock和信号量就能实现模式了,以下代码中我们通过子goroutine完成了生产者的功能,在在另一个子goroutine中实现了消费者的功能,注意要阻塞主goroutine以确保子goroutine能够执行,从而轻而易举的就这完成了生产者消费者模式。下面我们就通过具体实践中来看一下生产者消费者模型的实现。

package main

import (

"fmt"

"time"

)

func Product(ch chan<- int) { //生产者

for i := 0; i < 3; i++ {

fmt.Println("Product  produceed", i)

ch <- i //由于channel是goroutine安全的,所以此处没有必要必须加锁或者加lock操作.

}

}func Consumer(ch <-chan int) {

for i := 0; i < 3; i++ {

j := <-ch //由于channel是goroutine安全的,所以此处没有必要必须加锁或者加lock操作.

fmt.Println("Consmuer consumed ", j)

}

}func main() {

ch := make(chan int)

go Product(ch)//注意生产者与消费者放在不同goroutine中

go Consumer(ch)//注意生产者与消费者放在不同goroutine中

time.Sleep(time.Second * 1)//防止主goroutine退出

/*运行结果并不确定,可能为

Product  produceed 0

Product  produceed 1

Consmuer consumed  0

Consmuer consumed  1

Product  produceed 2

Consmuer consumed  2

*/

 

}

 

可以看到和Java比起来使用GO来实现并发式的生产者消费者模式的确是更为清爽了。

Rust的高并发实现

不得不说Rust的难度实在太高了,虽然笔者之前在汇编、C、Java等方面的经验可以帮助我快速掌握Go语言。但是假期看了两天Rust真想大呼告辞,这尼玛也太劝退了。在Rust官方提供的功能中,其实并不包括多生产者、多消费者的channel,std:sync空间下只有一个多生产者单消费者(mpsc)的channel。其样例实现如下:

use std::sync::mpsc;

use std::thread;

use std::time::Duration;

 

fn main() {

   let (tx, rx) = mpsc::channel();

   let tx1 = mpsc::Sender::clone(&tx);

   let tx2 = mpsc::Sender::clone(&tx);

 

   thread::spawn(move || {

       let vals = vec![

           String::from("1"),

           String::from("3"),

           String::from("5"),

           String::from("7"),

       ];

 

       for val in vals {

           tx1.send(val).unwrap();

           thread::sleep(Duration::from_secs(1));

       }

   });

 

   thread::spawn(move || {

       let vals = vec![

           String::from("11"),

           String::from("13"),

           String::from("15"),

           String::from("17"),

       ];

 

       for val in vals {

           tx.send(val).unwrap();

           thread::sleep(Duration::from_secs(1));

       }

   });

 

   thread::spawn(move || {

       let vals = vec![

           String::from("21"),

           String::from("23"),

           String::from("25"),

           String::from("27"),

       ];

 

       for val in vals {

           tx2.send(val).unwrap();

           thread::sleep(Duration::from_secs(1));

       }

   });

 

   for rec in rx {

       println!("Got: {}", rec);

   }

}

 

可以看到在Rust下实现生产者消费者是不难的,但是生产者可以clone多个,不过消费者却只能有一个,究其原因是因为Rust下没有GC也就是垃圾回收功能,而想保证安全Rust就必须要对于变更使用权限进行严格管理。在Rust下使用move关键字进行变更的所有权转移,但是按照Rust对于变更生产周期的管理规定,线程间权限转移的所有权接收者在同一时间只能有一个,这也是Rust官方只提供MPSC的原因,

use std::thread;

 

fn main() {

   let s = "hello";

 

   let handle = thread::spawn(move || {

       println!("{}", s);

   });

 

   handle.join().unwrap();

}

 

当然Rust下有一个API比较贴心就是join,他可以所有子线程都执行结束再退出主线程,这比Go中要手工阻塞还是要有一定的提高。而如果你想用多生产者、多消费者的功能,就要入手crossbeam模块了,这个模块掌握起来难度也真的不低。

总结

通过上面的比较我们可以用一张表格来说明几种主流语言的情况对比:

语言

安全性

运行速度

进程启动速度

学习难度

C

极快

极快

困难

Java

一般

一般

一般

Go

较快

较快

一般

Rust

极快(基本比肩C)

极快(基本比肩C)

极困难

可以看到Rust以其高安全性、基本比肩C的运行及启动速度必将在Serverless的时代独占鳌头,Go基本也能紧随其后,而C语言程序中难以避免的野指针,Java相对较低的运行及启动速度,可能都不太适用于函数式运算的场景,Java在企业级开发的时代打败各种C#之类的对手,但是在云时代好像还真没有之前统治力那么强了,真可谓是打败你的往往不是你的对手,而是其它空间的降维打击。

 

相关实践学习
【玩转ComfyUI】基于函数计算一键部署AI生图平台ComfyUI
本次实验将带大家通过使用阿里云产品函数计算FC,快速使用ComfyUI实现更高质量的图像生成。
从 0 入门函数计算
在函数计算的架构中,开发者只需要编写业务代码,并监控业务运行情况就可以了。这将开发者从繁重的运维工作中解放出来,将精力投入到更有意义的开发任务上。
相关文章
|
JavaScript 前端开发 Java
通义灵码 Rules 库合集来了,覆盖Java、TypeScript、Python、Go、JavaScript 等
通义灵码新上的外挂 Project Rules 获得了开发者的一致好评:最小成本适配我的开发风格、相当把团队经验沉淀下来,是个很好功能……
1962 103
|
8月前
|
安全 Java 编译器
对比Java学习Go——基础理论篇
本章介绍了Java开发者学习Go语言的必要性。Go语言以简单、高效、并发为核心设计哲学,摒弃了传统的类继承和异常机制,采用组合、接口和多返回值错误处理,提升了代码清晰度与开发效率。Go直接编译为静态二进制文件,启动迅速、部署简便,其基于Goroutine和Channel的并发模型相较Java的线程与锁机制更轻量安全。此外,Go Modules简化了依赖管理,与Java的Maven/Gradle形成鲜明对比,提升了构建与部署效率。
565 1
|
12月前
|
人工智能 Kubernetes Java
回归开源,两位 Java 和 Go 程序员分享的开源贡献指引
Higress是一个基于Istio和Envoy的云原生API网关,支持AI功能扩展。它通过Go/Rust/JS编写的Wasm插件提供可扩展架构,并包含Node和Java的console模块。Higress起源于阿里巴巴,解决了Tengine配置重载及gRPC/Dubbo负载均衡问题,现已成为阿里云API网关的基础。本文介绍Higress的基本架构、功能(如AI网关、API管理、Ingress流量网关等)、部署方式以及如何参与开源贡献。此外,还提供了有效的开源贡献指南和社区交流信息。
2036 33
|
8月前
|
数据采集 存储 弹性计算
高并发Java爬虫的瓶颈分析与动态线程优化方案
高并发Java爬虫的瓶颈分析与动态线程优化方案
|
8月前
|
存储 Java Go
对比Java学习Go——函数、集合和OOP
Go语言的函数支持声明与调用,具备多返回值、命名返回值等特性,结合`func`关键字与类型后置语法,使函数定义简洁直观。函数可作为一等公民传递、赋值或作为参数,支持匿名函数与闭包。Go通过组合与接口实现面向对象编程,结构体定义数据,方法定义行为,接口实现多态,体现了Go语言的简洁与高效设计。
246 4
|
8月前
|
存储 Java 编译器
对比Java学习Go——程序结构与变量
本节对比了Java与Go语言的基础结构,包括“Hello, World!”程序、代码组织方式、入口函数定义、基本数据类型及变量声明方式。Java强调严格的面向对象结构,所有代码需置于类中,入口方法需严格符合`public static void main(String[] args)`格式;而Go语言结构更简洁,使用包和函数组织代码,入口函数为`func main()`。两种语言在变量声明、常量定义、类型系统等方面也存在显著差异,体现了各自的设计哲学。
297 0
|
10月前
|
缓存 关系型数据库 MySQL
在MySQL中处理高并发和负载峰值的关键技术与策略
采用上述策略和技术时,每个环节都要进行细致的规划和测试,确保数据库系统既能满足高并发的要求,又要保持足够的灵活性来应对各种突发的流量峰值。实施时,合理评估和测试改动对系统性能的影响,避免单一措施可能引起的连锁反应。持续的系统监控和分析将对维护系统稳定性和进行未来规划提供重要信息。
450 15
|
10月前
|
Java Shell Maven
【Azure Container App】构建Java应用镜像时候遇无法编译错误:ERROR [build 10/10] RUN ./mvnw.cmd dependency:go-offline -B -Dproduction package
在部署Java应用到Azure Container App时,构建镜像过程中出现错误:“./mvnw.cmd: No such file or directory”。尽管项目根目录包含mvnw和mvnw.cmd文件,但依然报错。问题出现在Dockerfile构建阶段执行`./mvnw dependency:go-offline`命令时,系统提示找不到可执行文件。经过排查,确认是mvnw文件内容异常所致。最终通过重新生成mvnw文件解决该问题,镜像成功构建。
563 1
|
10月前
|
缓存 NoSQL Java
Java 项目实操高并发电商系统核心模块实现从基础到进阶的长尾技术要点详解 Java 项目实操
本项目实战实现高并发电商系统核心模块,涵盖商品、订单与库存服务。采用Spring Boot 3、Redis 7、RabbitMQ等最新技术栈,通过秒杀场景解决库存超卖、限流熔断及分布式事务难题。结合多级缓存优化查询性能,提升系统稳定性与吞吐能力,适用于Java微服务开发进阶学习。
524 0
|
人工智能 缓存 NoSQL
高并发秒杀系统设计:关键技术解析与典型陷阱规避
在电商、在线票务等场景中,高并发秒杀活动对系统性能和稳定性提出极大挑战。海量请求可能导致服务器资源耗尽、数据库锁争用及库存超卖等问题。通过飞算JavaAI生成的Redis + Lua分布式锁代码,可有效解决高并发下的锁问题,提升系统QPS达70%,同时避免缓存击穿与库存超卖。相较传统写法,AI优化代码显著提高性能与响应速度,为高并发系统开发提供高效解决方案。

相关产品

  • 函数计算