2024年11月

1.简介

上一篇中通过宏哥的介绍和讲解,小伙伴或者童鞋们应该知道宏哥今天要讲解和介绍的内容在哪里了吧,没错就是介绍那个OSI七层模型的传输层。因为只有它建立主机端到端的连接如:TCP、UDP。

2.TCP是什么?

tcp是工作在传输层,也就是网络层上一层的协议。

它是面向连接的,可靠的,基于字节流、全双工的通信协议。

TCP收到上一层的数据包后,会加上TCP头并且进行一些特殊处理后,再传递给网络层。

2.1TCP定义

传输控制协议(英语:Transmission Control Protocol,缩写:TCP)是一种面向连接的、可靠的、基于字节流的传输层通信协议,由IETF的RFC 793定义。在简化的计算机网络OSI模型中,它完成第四层传输层所指定的功能。用户数据报协议(UDP)是同一层内另一个重要的传输协议。

3.TCP理论

TCP提供了一种面向连接的、可靠的字节流服务。

面向连接:接双方在通信前需要预先建立一条连接,这犹如实际生活中的打电话。

  • 应用数据分割成TCP认为最适合发送的数据块。
  • 重传机制。设置定时器,等待确认包
  • 对首部和数据进行校验
  • TCP对收到的数据进行排序,然后交给应用层
  • TCP的接收端丢弃重复的数据
  • TCP还提供流量控制

TCP连接必须要经历三次握手,而释放一个TCP连接需要四次握手,这是由TCP的半关闭特性造成的。因为TCP连接时全双工的,因此,需要TCP两端要单独执行关闭。值得注意的是,主动关闭的一端在发送FIN之后,依然还能正常接收对方的数据,只是通知对方它已经没有数据需要发送了,同理,被动关闭的一端在收到FIN之后,仍然可以发送数据,直到它自身同样发出FIN之后,才停止发送数据。

4.什么是面向连接、无连接?

  • 面向连接:面向连接的协议要求发送数据前需要通过一种手段保证通信双方都准备好了,之后才进行通信。
  • 无连接:无连接的协议则不需要,想发就发

5.什么是全双工

全双工(Full Duplex)是一种通信方式,指通信的双方可以同时发送和接收数据,而不需要像半双工那样在发送和接收之间切换。在全双工通信中,数据可以在两个方向上同时传输,因此通信速度更快,效率更高。

6.OSI和封包详细信息的对应

为了更加清楚明白,宏哥这里将上一篇文章中的图拿过来进行说明和讲解。

7.
TCP包的具体内容

从下图可以看到wireshark捕获到的TCP包中的每个字段。

8.TCP报文格式

TCP是面向连接、可靠的传输协议,其报文格式较复杂。TCP报文的格式如下:


上图简化如下:

注意:实际的TCP报文段会根据TCP头部长度和可选项的不同而有所变化。

复制代码
|  源端口(16位)  |  目的端口(16位)  |
|   序号(32位)   |
| 确认序号(32位) |
|  数据偏移(4位) | 保留(6位) | 标志位(6位) | 窗口大小(16位) |
| 校验和(16位)   |  紧急指针(16位)  |
|   选项(可选)   |
|   数据(可选)   |
复制代码

主要字段解释:

  • 源端口:占 2 字节,标识数据包是哪个应用发出去的。
  • 目的端口:占 2 字节,标识数据包是发给哪个应用的。Source Port和Destination Port:分别占用16位,表示源端口号和目的端口号;用于区别主机中的不同进程,而IP地址是用来区分不同的主机的,源端口号和目的端口号配合上IP首部中的源IP地址和目的IP地址就能唯一的确定一个TCP连接。
  • 序号: 占 4 字节,TCP 连接中传送的
    数据流中的每一个字节都编上一个序号.序号字段的值则指的是本报文段所发送的数据的第一个字节的序号。
    Sequence Number:用来标识从TCP发端向TCP收端发送的数据字节流,它表示在这个报文段中的的第一个数据字节在数据流中的序号;主要用来解决网络报乱序的问题;
  • 确认号:占 4 字节,是期望收到对方的下一个报文段的数据的第一个字节的序号。Acknowledgment Number:32位确认序列号包含发送确认的一端所期望收到的下一个序号,因此,确认序号应当是上次已成功收到数据字节序号加1。不过,只有当标志位中的ACK标志(下面介绍)为1时该确认序列号的字段才有效。主要用来解决不丢包的问题。
  • 数据偏移(首部长度): 占 4 位,它指出 TCP 头部实际长度。Offset:给出首部中32 bit字的数目,需要这个值是因为任选字段的长度是可变的。这个字段占4bit(最多能表示15个32bit的的字,即4*15=60个字节的首部长度),因此TCP最多有60字节的首部。然而,没有任选字段,正常的长度是20字节。
    • 在 TCP 协议中,TCP 头部的长度是可变的,最小长度为 20 个字节,最大长度为 60 个字节。这是因为 TCP 头部中有一些可选字段,如 TCP 选项、窗口缩放因子等,这些字段的长度是可变的,因此 TCP 头部的长度也会随之变化。TCP 头部长度是通过 TCP 头部中的 数据偏移(首部长度)字段来指定的,它表示 TCP 头部的长度以 32 位字为单位计算的值。因此,TCP 头部长度实际上是 数据偏移(首部长度)字段值乘以 4。TCP Flags:TCP首部中有6个标志比特,它们中的多个可同时被设置为1,主要是用于操控TCP的状态机的,依次为
      URG

      ACK

      PSH

      RST

      SYN

      FIN
      。每个标志位的意思如下:
  • 状态位,占6比特:
    • URG:此标志表示TCP包的紧急指针域(后面马上就要说到)有效,用来保证TCP连接不被中断,并且督促中间层设备要尽快处理这些数据。
    • ACK:该位为 1 时,「确认号」的字段变为有效,否则无效。此标志表示应答域有效,就是说前面所说的TCP应答号将会包含在TCP数据包中;有两个取值:0和1,为1的时候表示应答域有效,反之为0; TCP协议规定,只有ACK=1时有效,也规定连接建立后所有发送的报文的ACK必须为1。
    • PSH:这个标志位表示Push操作。所谓Push操作就是指在数据包到达接收端以后,立即传送给应用程序,而不是在缓冲区中排队。
    • RST:该位为 1 时,表示 TCP 连接中出现异常必须强制断开连接,然后重新建立新链接。这个标志表示连接复位请求。用来复位那些产生错误的连接,也被用来拒绝错误和非法的数据包。
    • SYN:该位为 1 时,表示希望建立连接,并在其「序号」的字段进行序列号初始值的设定。
      表示同步序号,用来建立连接。
      SYN
      标志位和
      ACK
      标志位搭配使用,当连接请求的时候,
      SYN
      =1,
      ACK
      =0;连接被响应的时候,
      SYN
      =1,
      ACK
      =1;这个标志的数据包经常被用来进行端口扫描。扫描者发送一个只有
      SYN
      的数据包,如果对方主机响应了一个数据包回来 ,就表明这台主机存在这个端口;但是由于这种扫描方式只是进行TCP三次握手的第一次握手,因此这种扫描的成功表示被扫描的机器不很安全,一台安全的主机将会强制要求一个连接严格的进行TCP的三次握手。
    • FIN:该位为 1 时,表示数据发送传输完毕,希望断开连接。表示发送端已经达到数据末尾,也就是说双方的数据传送完成,没有数据可以传送了,发送
      FIN
      标志位的TCP数据包后,连接将被断开。这个标志的数据包也经常被用于进行端口扫描。
  • 窗口:占2字节,用于流量控制,通信双方各声明一个窗口,标识自己当前的处理能力。控制报文别发太快,也别发太慢。

    16位2字节,用于表示滑动窗口大小,窗口大小最大为65535(2^16-1)字节。
    接收方的流量控制手段,窗口大小为字节数,起始于确认序号字段指明的值,这个值是接收端正期望接收的字节。告诉发送端,接收端目前允许发送端数据量。大小两字节65535,在客户端与服务端 TCP 都允许的情况下,选项中可存在窗口扩展选项。 示例中:窗口大小65535,代表告诉发送方,从这个下一包0的序号开始,接收方只能接受65535个字节长度了(当然这里还没有算上扩展选项,稍后再讲)。

  • 检验和: 占 2 字节,校验数据是否完整未更改。

    16位2字节,检验和覆盖了整个的 TCP 报文段: TCP 首部和 TCP 数据。这是一个强制性的字段,一定是由发端计算和存储,并由收端进行验证。和 UDP 用户数据报一样,在计算检验和时,要在 TCP 报文段的前面加上12字节的伪首部。伪首部的格式和 UDP 用户数据报的伪首部一样。但应把伪首部第4个字段中的17改为6(TCP的协议号是6);把第5字段中的UDP中的长度改为TCP长度。接收方收到此报文段后,仍要加上这个伪首部来计算检验和。若使用TPv6,则相应的伪首部也要改变。
    校验和错误的分组丢弃(因为源IP地址、源端口号或者协议字段可能被破坏)。

  • 紧急指针:16位2字节,在紧急 URG 标志执1的时候有效,代表一个偏移量,和序号字段值相加,代表紧急数据最后一个字节的序号。
  • 选项:长度可变,最长可达40字节。当没有使用“选项”时,TCP 的首部长度是20字节。其最大长度可根据 TCP 首部长度进行推算。TCP 首部长度用4位数据偏移表示,单位是4字节,那么选项部分最长为:(2^4-1)*4-20=40字节。TCP 协议最初只规定了一种选项,即最长报文段长度(数据字段加上TCP首部),又称为 MSS。MSS 告诉对方 TCP “我的缓存所能接收的报文段的数据字段的最大长度是 MSS 个字节”。
  • 填充: 为了使整个首部长度是 4 字节的整数倍。选项长度是指不一定是32位的整数倍,所以要加填充位,即在这个字段中加入额外的0,以保证TCP头部是32的整数倍。

8.1TCP报文字段说明表格展示

有同学喜欢表格展示,宏哥也提供出来啦!

字段 长度 含义
Source Port 16比特 源端口,标识哪个应用程序发送。
Destination Port 16比特 目的端口,标识哪个应用程序接收。
Sequence Number 32比特 序号字段。TCP链接中传输的数据流中每个字节都编上一个序号。序号字段的值指的是本报文段所发送的数据的第一个字节的序号。
Acknowledgment Number 32比特 确认号,是期望收到对方的下一个报文段的数据的第1个字节的序号,即上次已成功接收到的数据字节序号加1。只有ACK标识为1,此字段有效。
Data Offset 4比特 数据偏移,即首部长度,指出TCP报文段的数据起始处距离TCP报文段的起始处有多远,以32比特(4字节)为计算单位。最多有60字节的首部,若无选项字段,正常为20字节。
Reserved 6比特 保留,必须填0。
URG 1比特 紧急指针有效标识。它告诉系统此报文段中有紧急数据,应尽快传送(相当于高优先级的数据)。
ACK 1比特 确认序号有效标识。只有当ACK=1时确认号字段才有效。当ACK=0时,确认号无效。
PSH 1比特 标识接收方应该尽快将这个报文段交给应用层。接收到PSH  = 1的TCP报文段,应尽快的交付接收应用进程,而不再等待整个缓存都填满了后再向上交付。
RST 1比特 重建连接标识。当RST=1时,表明TCP连接中出现严重错误(如由于主机崩溃或其他原因),必须释放连接,然后再重新建立连接。
SYN 1比特 同步序号标识,用来发起一个连接。SYN=1表示这是一个连接请求或连接接受请求。
FIN 1比特 发端完成发送任务标识。用来释放一个连接。FIN=1表明此报文段的发送端的数据已经发送完毕,并要求释放连接。
Window 16比特 窗口:TCP的流量控制,窗口起始于确认序号字段指明的值,这个值是接收端正期望接收的字节数。窗口最大为65535字节。
Checksum 16比特 校验字段,包括TCP首部和TCP数据,是一个强制性的字段,一定是由发端计算和存储,并由收端进行验证。在计算检验和时,要在TCP报文段的前面加上12字节的伪首部。
Urgent Pointer 16比特 紧急指针,只有当URG标志置1时紧急指针才有效。TCP的紧急方式是发送端向另一端发送紧急数据的一种方式。紧急指针指出在本报文段中紧急数据共有多少个字节(紧急数据放在本报文段数据的最前面)。
Options 可变 选项字段。TCP协议最初只规定了一种选项,即最长报文段长度(数据字段加上TCP首部),又称为MSS。MSS告诉对方TCP“我的缓存所能接收的报文段的数据字段的最大长度是MSS个字节”。
Padding 可变 填充字段,用来补位,使整个首部长度是4字节的整数倍。
data 可变 TCP负载。

9.小结

今天主要详细地介绍了一下TCP包的理论知识,基本上都是文字,看起来比较晦涩难懂。下一篇宏哥打算讲解和分享一下:TCP的三次握手和四次挥手以及WireShark的实践。好了,今天时间也不早了,宏哥就讲解和分享到这里,感谢您耐心的阅读,希望对您有所帮助。

场景

今天忽然临时接到一个需求:
就是将markdown文件直接在vue项目中进行加载,并正常显示出来。
这......,我知道是可以进行加载markdown文件的。
但是我之前没有做过,答复的是:可以做的,但是这个需要一点时间。
领导:那行,你先调研一下。

简单介绍 vue-markdown-loader

vue-markdown-loader可以将 Markdown 文件转换成Vue组件。
安装 npm i vue-markdown-loader -D

步骤1:在vue.config.js文件中去配置

module.exports = {
  chainWebpack:config=>{
    // 定义一个新的webpack模块规则,命名为md
    config.module.rule('md')
    // 通过.test()方法,指定这个规则应该匹配哪些文件
    // 这个规则将应用于所有以.md结尾的文件,即Markdown文件
      .test(/\.md/)
      // 使用vue-loader来处理Markdown文件
      .use('vue-loader')
      .loader('vue-loader')
      .end()
      // 指定vue-markdown-loader来处理Markdown文件
      .use('vue-markdown-loader')
      // 使用vue-markdown-loader包中的markdown-compiler模块来处理Markdown文件
      .loader('vue-markdown-loader/lib/markdown-compiler')
      // raw: true以原始字符串的形式处理Markdown内容,不进行HTML转义等处理。
      .options({
        raw: true
      })
  }
}

哦豁-项目启动报错

遇见的问题1:SyntaxError: Unexpected token '??='
产生问题的原因:你的node版本是否太低。
在项目中验证是否支持??=,可以验证一下。太低的话升级版本就行
还有一种可能:less-loader或者sass-loader或者其他的包的版本不对。

遇见的问题2: Syntax Error: TypeError: Cannot read property ‘styles‘ of undefined
产生问题的原因:vue-loader的版本太高造成的。
我的项目是webpack的版本是:webpack5,它对应的vue-loader应该是vue-loader15,
我将它降级为:vue-loader@15

步骤2:在使用的页面

<template>
  <div>
    <showMarkdown></showMarkdown>
  </div>
</template>
<script>
// 引入的
import showMarkdown from './biji.md'
export default {
  components:{
    showMarkdown
  },
  data() {
    return {
    
    }
  }
}
</script>

发现问题:优化样式

我们需要下载 github-markdown-css
npm i github-markdown-css -S
这个是用来优化markdown展示出来的样式
能够保持与GitHub相同的视觉效果
在需要的文件中引入 import 'github-markdown-css';
然后我们在组件的父级使用markdown-body这个类来美化markdown

<template>
  <div>
    <div class="markdown-body">
      <showMarkdown></showMarkdown>
    </div>
  </div>
</template>

<script>
import 'github-markdown-css';
import showMarkdown from './biji.md'
export default {
  components:{
    showMarkdown
  }
}
</script>

可以把markdown文件在路由中引入吗?

有的小伙伴说:既然我们能够在页面中引入当成组件,
那可以在路由文件中引入嘛?
回答:可以的。下面我们就来看下

const routes = [
  {
    path: '/',
    name: 'Home',
    component: ()=>import("../views/echarts.vue")
  },
  {
    path: '/xuexi',
    name: 'xuexi',
    component: ()=>import("../views/xuexi.vue")
  },
  {
    path: '/md',
    name: 'md',
    // 引入的md文件
    component: ()=>import("../views/biji.md")
  },
]
const router = new VueRouter({
  mode: 'hash',
  base: process.env.BASE_URL,
  routes
})

main.js中引入 'github-markdown-css';

import Vue from 'vue'
import App from './App.vue'
import router from './router'
//全局引入
import 'github-markdown-css';
import ElementUI from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';
Vue.config.productionTip = false

Vue.use(ElementUI);
new Vue({
  router,
  render: h => h(App)
}).$mount('#app')

app.vue使用样式

<template>
  <div id="app" class="markdown-body">
    <div id="nav">
      <router-link to="/">Home</router-link> |
      <router-link to="/about">About</router-link> |
      <router-link to="/about">About</router-link> |
      <router-link to="/echarts">echarts</router-link> |
      <router-link to="/art">art</router-link> |
      <router-link to="/test">test</router-link> |
      <router-link to="/xuexi">xuexi</router-link> |
    </div>
    <router-view/>
  </div>
</template>

发现问题:markdown-body 污染了全局样式

我们发现这样整个项目中都有 markdown-body 这个类了。
这样会影响其他组件的布局样式。
我们只想在引入的文件是md才有这个样式。
其他的文件没有这个样式。
这个是否我们可以在app.vue文件中判断是否是md文件。
如果是md文件我们添加上markdown-body这个类,否则移除。
我们在路由文件中的meta属性来判断是否是md文件类型

路由文件

const routes = [
 {
    path: '/md',
    name: 'md',
    component: ()=>import("../views/biji.md"),
    meta:{
      fileType:'md'
    }
  },
  {
    path: '/amd',
    name: 'amd',
    component: ()=>import("../views/amd.md"),
    meta:{
      fileType:'md'
    }
  }
]

app.vue

<template>
  <div id="app" :class="componentPathName=='md' ? 'markdown-body' : null">
    <div id="nav">
      <router-link to="/">Home</router-link> |
      <router-link to="/about">About</router-link> |
      <router-link to="/about">About</router-link> |
      <router-link to="/echarts">echarts</router-link> |
      <router-link to="/art">art</router-link> |
      <router-link to="/test">test</router-link> |
      <router-link to="/xuexi">xuexi</router-link> |
    </div>
    <router-view/>
  </div>
</template>
<script>
 export default {
  computed: {
    componentPathName () {
      return this.$route.meta && this.$route.meta.fileType
    }
  },
 }
</script>

md文件内容有些时候是从服务端获取的

上面我们渲染的都是本地的文件.
如果 markdown 的内容是从服务端获取的。
动态渲染怎么去处理呢?
我们需要下载 vue-markdown
npm install vue-markdown --save
然后在vue.config.js文件中去配置,与上面的配置相同(一样的哈)

vue-markdown

它允许在Vue应用中轻松展示Markdown格式的内容。
它支持标准的Markdown语法。
如标题、列表、链接、图片、代码块等,并能够将Markdown文本解析为HTML格式。
从而在Vue组件中展示。

vue-markdown 的简单使用

<template>
  <div>
    <VueMarkdown>
     {{ mdCont }}
    </VueMarkdown>
  </div>
</template>

<script>
import VueMarkdown from 'vue-markdown';
export default {
  components:{
    VueMarkdown
  },
  data() {
    return{
      mdCont:'#### 绘制一个矩形的思路我们这里绘制矩形\n会使用到canvas.strokeRect(x,y, w, h)方法绘制一个描边矩形![](https://p0-xtjj-private.juejin.cn/tos-cn-i-73owjymdk6/91e6190a5cdf4b548cbb7db766acb01c~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5oiR55qEZGl25Lii5LqG6IK_5LmI5Yqe:q75.awebp?policy=eyJ2bSI6MywidWlkIjoiMTMxMDI3MzU5MzQ0MDM5OCJ9&rk3s=f64ab15b&x-orig-authkey=f32326d3454f2ac7e96d3d06cdbb035152127018&x-orig-expires=1733142556&x-orig-sign=Zghvt5lD2jYz6D0D0SaZye5cgos%3D)'
    }
  }
}
</script>

远端请求的内容为啥渲染失败

<template>
  <div>
    <VueMarkdown>
     {{ mdCont }}
    </VueMarkdown>
  </div>
</template>

<script>
import VueMarkdown from 'vue-markdown';
export default {
  components:{
    VueMarkdown
  },
  data() {
    return{
      mdCont:'', //返回来的内容
      showKey: '0',
    }
  },
  created(){
    this.serveAPi()
  },
  methods:{
    serveAPi(){
      setTimeout(() => {
        this.mdCont = '#### 绘制一个矩形的思路我们这里绘制矩形\n会使用到canvas.strokeRect(x,y, w, h)方法绘制一个描边矩形![](https://p0-xtjj-private.juejin.cn/tos-cn-i-73owjymdk6/91e6190a5cdf4b548cbb7db766acb01c~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5oiR55qEZGl25Lii5LqG6IK_5LmI5Yqe:q75.awebp?policy=eyJ2bSI6MywidWlkIjoiMTMxMDI3MzU5MzQ0MDM5OCJ9&rk3s=f64ab15b&x-orig-authkey=f32326d3454f2ac7e96d3d06cdbb035152127018&x-orig-expires=1733142556&x-orig-sign=Zghvt5lD2jYz6D0D0SaZye5cgos%3D)'
        //更新设置这个key值
        this.showKey = new Date().getTime()+ ''
      },400)
    }
  },
}
</script>

我们发现md无法正常渲染,但是直接写在data中的是可以渲染的。
说明返回来的数据,在渲染的时候组件没有重新更新。
我们只需要使用key更新更新一下就行了。

远端请求内容渲染markdown,key更新组件

<template>
  <div>
    <!-- 更新渲染这个组件,要不然返回来的数据无法正常渲染 -->
    <VueMarkdown :key="showKey">
     {{ mdCont }}
    </VueMarkdown>
  </div>
</template>

<script>
import VueMarkdown from 'vue-markdown';
export default {
  components:{
    VueMarkdown
  },
  data() {
    return{
      mdCont:'', //返回来的内容
      showKey: '0',
    }
  },
  created(){
    this.serveAPi()
  },
  methods:{
    serveAPi(){
      setTimeout(() => {
        this.mdCont = '#### 绘制一个矩形的思路我们这里绘制矩形\n会使用到canvas.strokeRect(x,y, w, h)方法绘制一个描边矩形![](https://p0-xtjj-private.juejin.cn/tos-cn-i-73owjymdk6/91e6190a5cdf4b548cbb7db766acb01c~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5oiR55qEZGl25Lii5LqG6IK_5LmI5Yqe:q75.awebp?policy=eyJ2bSI6MywidWlkIjoiMTMxMDI3MzU5MzQ0MDM5OCJ9&rk3s=f64ab15b&x-orig-authkey=f32326d3454f2ac7e96d3d06cdbb035152127018&x-orig-expires=1733142556&x-orig-sign=Zghvt5lD2jYz6D0D0SaZye5cgos%3D)'
        //更新设置这个key值
        this.showKey = new Date().getTime()+ ''
      },400)
    }
  },
}
</script>

作者:
秦怀

1 缓存前世今生

1.1 故事从硬件开始

Cache 一词来源于 1967 年的一篇电子工程期刊论文。其作者将法语词“cache”赋予“safekeeping storage”的涵义,用于电脑工程领域。当时没有 Cache,CPU 和内存都很慢,CPU 直接访问内存。

  • Intel 80386
    芯片组增加了对可选的 Cache 的支持,高级主板带有 64KB,甚至高端的 128KB Write-Through Cache。
  • Intel 80486
    CPU 里面加入了 8KB 的 L1 Unified Cache,当时也叫做内部 Cache,不分代码和数据,都存在一起;芯片组中的 Cache,变成了 L2,也被叫做外部 Cache,从 128KB 到 256KB 不等;增加了 Write-back 的 Cache 属性。
  • Pentium (奔腾)
    CPU 的 L1 Cache 分为 Code 和 data,各自 8KB;L2 还被放在主板上。
  • Pentium Pro(奔腾)
    的 L2 被放入到 CPU 的 Package 上。
  • Pentium 3(奔腾)
    开始,L2 Cache 被放入了 CPU 的 Die 中。

  • Intel Core CPU
    开始,L2 Cache 为多核共享。

当 CPU 处理数据时,它会先到 Cache 中去寻找,如果数据因之前的操作已经读取而被暂存其中,就不需要再从 随机存取存储器(Main memory)中读取数据——由于 CPU 的运行速度一般比主内存的读取速度快,主存储器周期(访问主存储器所需要的时间)为数个时钟周期。因此若要访问主内存的话,就必须等待数个 CPU 周期从而造成浪费。

提供“缓存”的目的是为了让数据访问的速度适应 CPU 的处理速度,其基于的原理是内存中“程序执行与数据访问的局域性行为”,即一定程序执行时间和空间内,被访问的代码集中于一部分。为了充分发挥缓存的作用,不仅依靠“暂存刚刚访问过的数据”,还要使用硬件实现的
指令预测

数据预取
技术——尽可能把将要使用的数据预先从内存中取到缓存里。

CPU 的缓存曾经是用在超级计算机上的一种高级技术, 不过现今电脑上使用的的 AMD 或 Intel 微处理器都在芯片内部集成了大小不等的数据缓存和指令缓存, 通称为 L1 缓存 (L1 Cache 即 Level 1 On-die Cache, 第一级片上高速缓冲存储器);
而比 L1 更大容量的 L2 缓存曾经被放在 CPU 外部 (主板或者 CPU 接口卡上), 但是现在已经成为 CPU 内部的标准组件; 更昂贵的 CPU 会配备比 L2 缓存还要大的 L3 缓存 (level 3 On-die Cache 第三级高速缓冲存储器)

1.2 概念的扩展

如今缓存的概念已被扩充, 不仅在 CPU 和主内存之间有 Cache, 而且在内存和硬盘之间也有 Cache (磁盘缓存), 乃至在硬盘与网络之间也有某种意义上的 Cache 称为 Internet 临时文件夹或网络内容缓存等
凡是位于速度相差较大的两种硬件之间, 用于协调两者数据传输速度差异的结构, 均可称之为 Cache

现在我们软件开发中常说的缓存,是指磁盘和 CPU 之间的,协调两者传输速度的结构。

2 缓存的特征

2.1 主要特征

  • 命中率:命中率=返回正确结果数/请求缓存次数,命中率越高,表明缓存的使用率也就越高。
  • 吞吐量:缓存的吞吐量使用 OPS 值(每秒操作数,Operations per Second,ops/s)来衡量,反映了对缓存进行
    并发
    读、写操作的效率,即缓存本身的工作效率高低。
  • 缓存淘汰策略:
    • FIFO (first in first out)
      :先进先出策略,最先进入缓存的数据在缓存空间不够的情况下(超出最大元素限制)会被优先被清除掉,以腾出新的空间接受新的数据。
    • LFU (less frequently used)
      :最少使用策略,无论是否过期,根据元素的被使用次数判断,清除使用次数较少的元素释放空间。
    • LRU (least recently used)
      :最近最少使用策略,无论是否过期,根据元素最后一次被使用的时间戳,清除最远使用时间戳的元素释放空间。

2.2 是否适合缓存的考虑

不是所有数据都适合缓存,我们使用缓存,是想用较小的成本换取较大的收益,在决定是否缓存之前,可以考虑以下的问题:

  • 是否有一致性的要求,缓存和底层存储是否需要强一致性
  • 缓存是不是高效的?命中率大概怎么样?
  • 缓存多久,是否需要设置 TTL
  • 数据结构是否适合缓存
  • 计算后缓存,亦或是缓存之后计算

2.3 惊群效益

如果许多不同的应用程序进程同时请求一个缓存键,但出现缓存未命中,随后所有应用程序进程都并行执行相同的数据库查询,此时就会发生惊群效应,也称作叠罗汉效应。此查询的代价越高,对数据库的影响就越大。一般可以通过缓存预热、缓存不存在的空值来减少。

3 缓存的分类

根据应用的耦合度,一般分为本地缓存和分布式缓存:

  • 本地缓存:在应用中的缓存组件,应用和 Cache 是在同一个进程内,请求特别快,没有网络开销。
  • 分布式缓存:与应用分离的缓存组件,可以认为是独立的服务,和应用分开,多个应用之间可以共享,但是会存在网络请求。

4 分布式缓存存在的必要性

先聊缓存的必要性,计算机的世界里,倘若有无法解决不了的问题,一般都可以再加一层来解决,而缓存从被提出开始,就是那个加了的一层。CPU的速度很快,数据库操作很慢,怎么办?CPU缓存很小很贵很快,但是数据库的磁盘很慢很大很便宜,怎么办?内存来解决!

可以提前将一些比较耗时的数据结果暂存到内存(
如果有持久化,也会同时存储在磁盘中
)中,如果有相同请求,可以直接返回,如果数据变更(更新或者删除),再处理掉缓存。大家平日里接触最多的,可能就是浏览器的缓存,有时候多次访问,有些数据根本不会再去请求,会优先使用浏览器的本地缓存。
除此之外,微博也是如此,

单机的缓存,可以满足大部分的场景,但是单节点的最大容量不能超过整个系统的内存,而且像 memcached 这种存储,断电内容就会彻底丢失,Redis 则有持久化的能力,只是通电之后需要花点时间从磁盘将数据 load 回内存中。

现在几乎应用服务器都是分布式的,如果只做单机缓存,意味着每个服务器的缓存,都存了一份,极大概率存在不一致的情况,比如 一个用户第一次请求命中机器 A,有缓存,第二次命中机器 B ,又没缓存,只能重新缓存了一份在机器 B 上。

5 分布式缓存设计可能需要考虑的几个问题

站在巨人(Redis)的肩膀上, 我们可以学到很多优秀的设计、理念,设计一个功能比较全面的分布式缓存,到底需要考虑哪些问题?

下面聊聊几点比较常见的:

5.1 、断电了怎么办?(持久化)

必须支持
持久化
,可以异步的将数据刷盘,落到磁盘中,重新启动的时候能够加载已有的数据。那刷盘的时机是怎么样的?只要改一个数据就刷一次盘么?还是修改数据到达某个阈值,才进行刷盘,这些都是策略,最好是可以支持配置,这些规则其实我们都可以从 Redis 这些优秀的缓存中间件中学习到。
当然,如果在一定场景下,能接受数据完全丢失,不需要持久化,那么可以设置为关闭,可以节约性能开销。

5.2 2、内存不足怎么办?(缓存淘汰策略)

单机内存不足,可以删除一些数据。但是到底删除哪些数据,这必须有一个决策的算法,这就是缓存淘汰策略。
常见的缓存淘汰策略有以下几种:

  • FIFO:先进先出(First In,First Out),如同队列,新数据在尾部加入,内存不足的时候,淘汰的数据从队列头部移除。
  • LFU:最低频率使用淘汰算法(Least Frequently Used),也称为最近最不常使用,将使用频率最低的数据淘汰。
  • LRU:最近时间未使用(Least Recently used),也称为最近最少使用,内存不足的时候,总是淘汰最长时间未被使用得数据。

5.3 3、需不需要自定义协议?

一个稳定的分布式缓存系统,还需要一套序列化协议,怎么设计一个简单而又高效的协议,是个值得思考的问题。

比如 Redis 使用得就是
RESP(REdis Serialization Protocol)
协议,这是专门为 Redis 设计的,属于应用层的通信协议,本质上和 HTTP 是同一层级,而 Redis 的传输层使用的是 TCP。如果是服务器接收请求的场景,那么服务端从 TCPsocket 缓存区里面读取数据,然后经过了 RESP 协议解码知乎,会得到我们所需的指令。

简单讲一下,RESP 主要就是
想用更少的数据,表达所需的更丰富的内容,也就是压缩数据量,增加信息量。
比如第一个字节,决定了数据类型:

  • 简单字符串
    :Simple Strings,第一个字节响应
    +
  • 错误
    :Errors,第一个字节响应
    -
  • 整型
    :Integers,第一个字节响应
    :
  • 批量字符串
    :Bulk Strings,第一个字节响应
    $
  • 数组
    :Arrays,第一个字节响应
    *

5.4 4、一台机器存储不够怎么办?(可拓展)

不能一直增加单台机器的容量,抛开成本不讲,单机大容量,网络带宽,磁盘 IO,计算资源等都可能成为较大的瓶颈,肯定需要支持横向拓展(水平拓展),比如 Redis 集群模式。与横向拓展对应的是垂直拓展,也就是增加单个节点的容量,性能。互联网发展的这些年,已经证明了分布式系统是一个更优的选项。

5.5 5、如果有一台机器宕机了怎么办?(高可用)

如果多台机器中,有机器宕机怎么办?从事前、事中、事后来看:

  • 事前:需要可监控,需要有监控节点(比如 Redis 中的哨兵),并且有可以切换的节点(从节点)。
  • 事中:怎么切换,哪一个机器作为“主持人“角色进行切换,切换哪一个机器,都是需要抉择的。
  • 事后:切换之后,下线机器怎么处理。

5.6 6、是否支持并发?(高并发)

并发写入怎么办?Redis 采取的是队列的方式,内部不允许并发执行,也就不需要加锁,解锁的操作,如果考虑使用锁来实现,需要同时考虑上下文切换的成本,而我们简单的版本可以使用加锁的方式来实现。

6 使用分布式缓存可能会遇到的几个问题

6.1 1、一致性问题

如何保证缓存和数据库的一致性问题,是一个比较大的话题,我们除了保证数据库和缓存一致,分布式缓存的 master 和 slave 也需要保持一致。一般一致性分为以下几种:

  • 强一致性:数据库更新操作与缓存更新操作是原子性的,缓存与数据库的数据在任何时刻都是一致的,很难实现。
  • 弱一致性:当数据更新后,缓存中的数据可能是更新前的值,也可能是更新后的值,这种更新是异步的。
  • 最终一致性:一种特殊的弱一致性,在一定时间后,数据会达到一致的状态。最终一致性是弱一致性的理想状态,也是分布式系统的数据一致性解决方案上比较推崇的。

根据 CAP 原理,分布式系统在可用性、一致性和分区容错性上无法兼得,通常由于分区容错无法避免,所以一致性和可用性难以同时成立。

这里的几种方案就不展开讲了,几种更新策略:

  • 1、先更新缓存,再更新数据库:
    • 在两个线程一起更新的场景下,如果先更新缓存的线程后更新数据库,很容易出现一致性问题。
  • 2、先更新数据库,再更新缓存
    • 在两个线程一起更新的场景下,如果先更新数据库的线程由于执行慢了一些,后更新缓存,很容易出现一致性问题。
  • 3、先删除缓存,再更新数据库
    • 先删除缓存的线程,后更新数据库,仍然有一致性问题
  • 4、先更新数据库,再删除缓存
    • 先更新数据库的线程,后删除缓存,没有问题!删除缓存之后,会回源到数据库。
    • 但是没删除缓存之前,数据库更新了,读取会读到脏数据。所以我们一般推荐双删,更新之前删一次,更新之后删一次。
    • 这个时候有人会问,如果同时有个读请求,读的是写之前的脏数据,但是写入到缓存是比较慢的,刚刚好在删除之后,那缓存数据就还是脏数据?是的,这个时候一般靠第二次删除延迟来处理,延迟删除。
    • 这个时候肯定有人问,那要是删除失败了怎么办?
      • 直接补偿重试
      • 消息队列,异步重试
      • 基于 mysql binlog 增量订阅消费补偿

这个问题我们在这个分布式缓存的里面就不详细聊了,之后单独聊这个话题,
串行化是我们最后的倔强
,但是高并发就难了,所以我们一般是保证最终一致性即可。

6.2 2、缓存穿透

缓存穿透是指,
缓存和数据库都没有的数据
,被大量请求,比如订单号不可能为
-1
,但是用户请求了大量订单号为
-1
的数据,由于数据不存在,缓存就也不会存在该数据,所有的请求都会直接穿透到数据库。
如果被恶意用户利用,疯狂请求不存在的数据,就会导致数据库压力过大,甚至垮掉。
注意:穿透的意思是,都没有,直接一路打到数据库。

那对于这种情况,我们该如何解决呢?

  1. 接口增加业务层级的
    Filter
    ,进行合法校验,这可以有效拦截大部分不合法的请求。

  2. 作为第一点的补充,最常见的是使用
    布隆过滤器
    ,针对一个或者多个维度,把可能存在的数据值 hash 到 bitmap 中,bitmap 证明该数据不存在则该数据一定不存在,但是 bitmap 证明该数据存在也只能是可能存在,因为不同的数值 hash 到的 bit 位很有可能是一样的,hash 冲突会导致误判,多个 hash 方法也只能是降低冲突的概率,无法做到避免。

  3. 另外一个常见的方法,则是针对数据库与缓存都没有的数据,对空的结果进行缓存,但是过期时间设置得较短,一般五分钟内。而这种数据,如果数据库有写入,或者更新,必须同时刷新缓存,否则会导致不一致的问题存在。

6.3 3、缓存雪崩

缓存雪崩是指缓存中有大量的数据,在同一个时间点,或者较短的时间段内,全部过期了,这个时候请求过来,缓存没有数据,都会请求数据库,则数据库的压力就会突增,扛不住就会宕机。
针对这种情况,一般我们都是使用以下方案:

  1. 如果是热点数据,先预热,而且可以考虑设置永远不过期。
  2. 缓存的过期时间除非比较严格,要不考虑设置一个波动随机值,比如理论十分钟,那这类key的缓存时间都加上一个1
    3分钟,过期时间在7
    13分钟内波动,有效防止都在同一个时间点上大量过期。
  3. 方法1避免了有效过期的情况,但是要是所有的热点数据在一台redis服务器上,也是极其危险的,如果网络有问题,或者redis服务器挂了,那么所有的热点数据也会雪崩(查询不到),因此将热点数据打散分不到不同的机房中,也可以有效减少这种情况。
  4. 也可以考虑双缓存的方式,数据库数据同步到缓存 A 和 B,A 设置过期时间,B 不设置过期时间,如果 A 为空的时候去读 B,同时异步去更新缓存,但是更新的时候需要同时更新两个缓存。
  5. 使用缓存组件时,可以设置为异步回源,或者允许读取未物理删除的数据。

比如设置产品的缓存时间:


redis.set(id,value,60*60 + Math.random()*1000);

6.4 4、缓存击穿

缓存击穿是指数据库原本有得数据,但是缓存中没有,一般是缓存突然失效了,这时候如果有大量用户请求该数据,缓存没有则会去数据库请求,会引发数据库压力增大,可能会瞬间打垮。

针对这类问题,一般有以下做法:

  1. 如果是热点数据,那么可以考虑设置永远不过期。
  2. 如果数据一定会过期,那么就需要在数据为空的时候,设置一个互斥的锁,只让一个请求通过,只有一个请求去数据库拉取数据,取完数据,不管如何都需要释放锁,异常的时候也需要释放锁,要不其他线程会一直拿不到锁。

下面是缓存击穿的时候互斥锁的写法,注意:获取锁之后操作,不管成功或者失败,都应该释放锁,而其他的请求,如果没有获取到锁,应该等待,再重试。当然,如果是需要更加全面一点,应该加上一个等待次数,比如1s中,那么也就是睡眠五次,达到这个阈值,则直接返回空,不应该过度消耗机器,以免当个不可用的场景把整个应用的服务器带挂了。

    public static String getProductDescById(String id) {
        String desc = redis.get(id);
        // 缓存为空,过期了
        if (desc == null) {
            // 互斥锁,只有一个请求可以成功
            if (redis.setnx(lock_id, 1, 60) == 1) {
                try {
                    // 从数据库取出数据
                    desc = getFromDB(id);
                    redis.set(id, desc, 60 * 60 * 24);
                } catch (Exception ex) {
                    LogHelper.error(ex);
                } finally {
                    // 确保最后删除,释放锁
                    redis.del(lock_id);
                    return desc;
                }
            } else {
                // 否则睡眠200ms,接着获取锁
                Thread.sleep(200);
                return getProductDescById(id);
            }
        }
    }

6.5 5、缓存热点

像微博这种,有些热点新闻,突然爆了,大量用户访问同一个 key,key 在同一个缓存节点,很容易就过载,节点会卡顿甚至挂掉,这种我们就叫缓存热点。

解决方案一般是通过实时数据流比如 Spark ,分析热点 Key ,一般都有一个增长的过程,然后在 Key 后面加上一些随机的编号,比如明星出轨_01, 明星出轨_02...,目的是让这些 key 分布在不同的机器上,而客户端获取的时候,带上随机的 key,随机访问一个就可以。

想要探测热 Key,除了实时数据流,也可以在 redis 之上的 proxy 上面做,一般我们在公司都不是直接连接 redis ,而是连接的 proxy,因此我们也可以通过在 proxy 中使用滑动时间窗口,对每个 key 进行计数,超过一定的阈值,就设置为热 key。

那如何快速针对热 key 进行动态处理呢?弄一个独立的缓存数据服务,根据流量来动态拆分热 key,动态的增长成为热 key 我们可以通过分析发现,但是如果是秒杀等业务呢?需要支持实时拆分热 key,用分布式配置中心来配置热 key,感知到配置热 key 则进行需要的处理,这里因业务而异,可以降级成读取本地内存,可以进行拆分等等。

当然,如果能够正对秒杀等活动,或者大促活动,拉出独立的集群进行路由,隔离影响,那也是一种方案。

这是京东的处理方案:
https://gitee.com/jd-platform-opensource/hotkey
,对任意突发性的无法预先感知的热点请求,包括并不限于热点数据(如突发大量请求同一个商品)、热用户(如爬虫、刷子)、热接口(突发海量请求同一个接口)等,进行毫秒级精准探测到。 然后对这些热数据、热用户等,推送到该应用部署的所有机器JVM内存中,以大幅减轻对后端数据存储层的冲击,并可以由客户端决定如何使用这些热key(譬如对热商品做本地缓存、对热用户进行拒绝访问、对热接口进行熔断或返回默认值)。 这些热key在整个应用集群内保持一致性。

6.6 6、缓存大 Key

缓存大 key 是指缓存的值 value 特别大,如果同一时间大量请求访问了同一个大 key,带宽很容易被占满,其他请求进不来。

大 key 定义参考如下:

  • string类型的key超过10KB
  • hash/set/zset/list 等数据结构中元素个数大于 5k/整体占用内存大于 10MB

如何判断是不是大 key,
一般看网络的出流量,如果突增特别厉害,但是入流量变化不大的情况下,基本可以判断为大 key

  • 事前我们可以在代码 review 的时候就得判断 value 是不是特别大,不能写这种代码。或者封装一层 redis 操作切面,异步对 key 的 value 做监控,进行打点告警。
  • 其次,写代码的时候如果发现要 set 这种大的 value 值,那就得想办法拆分,把对象拆成属性,或者按照属性分类。如果是一个不可分割的整体,那就得考虑一下技术方案是不是要推翻重来了,一般我们不太可能把几 M 的图片直接二进制存 redis。
  • dump RDB 数据,进行离线数据分析,给出告警,但是不够实时。
  • Redis 提供了 bigkeys 参数能够使 redis-cli 以遍历的方式分析 Redis 实例中的所有 Key,并返回 Key 的整体统计信息与每个数据类型中 Top1 的大 Key,bigkeys 仅能分析并输入六种数据类型(
    STRING

    LIST

    HASH

    SET

    ZSET

    STREAM
    ), 命令示例为
    redis-cli -h 127.0.0.1 -p 6379 --bigkeys

7 总结

缓存不是银弹,是一把刀,用得好,可以乱杀(夸大),用不好,得包扎(一点不夸大,得提桶跑路那种)。

你还记得2023年那篇比较各种流行编程语言异步编程
内存消耗比较
的文章吗?

现在是2024年底,我很好奇在一年时间里,随着各种语言的最新版本发布,情况有什么变化。

让我们再次进行基准测试,看看结果!

基准

用于基准测试的程序与去年相同:

让我们启动 N 个并发任务,每个任务等待 10 秒,然后在所有任务完成后程序退出。任务的数量由命令行参数控制。

这次,让我们专注于协程而不是多线程。

所有基准代码可以在
async-runtimes-benchmarks-2024
访问。

什么是协程?

协程是计算机程序的一种组件,能够暂停和恢复执行。这使得它比传统的线程更灵活,特别适合用于处理需要协作的多任务操作,比如实现任务协作、异常处理、事件循环、迭代器、无限列表和数据管道等功能。

Rust

我用 Rust 创建了 2 个程序。一个使用
tokio

use std::env;
use tokio::time::{sleep, Duration};

#[tokio::main]
async fn main() {
    let args: Vec<String> = env::args().collect();
    let num_tasks = args[1].parse::<i32>().unwrap();
    let mut tasks = Vec::new();
    for _ in 0..num_tasks {
        tasks.push(sleep(Duration::from_secs(10)));
    }
    futures::future::join_all(tasks).await;
}

而另一个使用
async_std

use std::env;
use async_std::task;
use futures::future::join_all;
use std::time::Duration;

#[async_std::main]
async fn main() {
    let args: Vec<String> = env::args().collect();
    let num_tasks = args[1].parse::<usize>().unwrap();

    let mut tasks = Vec::new();
    for _ in 0..num_tasks {
        tasks.push(task::sleep(Duration::from_secs(10)));
    }

    join_all(tasks).await;
}

两者都是 Rust 中常用的异步运行时。

C#

C#,与 Rust 类似,对 async/await 提供了一流的支持:

int numTasks = int.Parse(args[0]);
List<Task> tasks = new List<Task>();

for (int i = 0; i < numTasks; i++)
{
    tasks.Add(Task.Delay(TimeSpan.FromSeconds(10)));
}

await Task.WhenAll(tasks);

自 .NET 7 起,.NET 还提供了 NativeAOT 编译,它将代码直接编译为最终的二进制文件,因此不再需要 VM 来运行托管代码。因此,我们也添加了 NativeAOT 的基准测试。

NodeJS

NodeJS 也是如此:

const util = require('util');
const delay = util.promisify(setTimeout);

async function runTasks(numTasks) {
  const tasks = [];

  for (let i = 0; i < numTasks; i++) {
    tasks.push(delay(10000));
  }

  await Promise.all(tasks);
}

const numTasks = parseInt(process.argv[2]);
runTasks(numTasks);

Python

还有 Python:

import asyncio
import sys

async def main(num_tasks):
    tasks = []

    for task_id in range(num_tasks):
        tasks.append(asyncio.sleep(10))

    await asyncio.gather(*tasks)

if __name__ == "__main__":
    num_tasks = int(sys.argv[1])
    asyncio.run(main(num_tasks))

Go

在 Go 语言中,goroutine 是实现并发的关键。我们不需要逐个等待 goroutine ,而是通过
WaitGroup
来统一管理:

package main

import (
    "fmt"
    "os"
    "strconv"
    "sync"
    "time"
)

func main() {
    numRoutines, _ := strconv.Atoi(os.Args[1])
    var wg sync.WaitGroup
    for i := 0; i < numRoutines; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            time.Sleep(10 * time.Second)
        }()
    }
    wg.Wait()
}

Java

自 JDK 21 起,Java 提供了虚拟线程,这与协程的概念相似:

import java.time.Duration;
import java.util.ArrayList;
import java.util.List;

public class VirtualThreads {

    public static void main(String[] args) throws InterruptedException {
	    int numTasks = Integer.parseInt(args[0]);
        List<Thread> threads = new ArrayList<>();

        for (int i = 0; i < numTasks; i++) {
            Thread thread = Thread.startVirtualThread(() -> {
                try {
                    Thread.sleep(Duration.ofSeconds(10));
                } catch (InterruptedException e) {
                    // Handle exception
                }
            });
            threads.add(thread);
        }

        for (Thread thread : threads) {
            thread.join();
        }
    }
}

Java有一个新的 JVM 变体叫做 GraalVM。GraalVM 还提供本机镜像,这与.NET 中的 NativeAOT 概念相似。因此,我们也为 GraalVM 添加了基准测试。

测试环境

  • 硬件:第 13 代英特尔(R)酷睿(TM) i7-13700K
  • 操作系统:Debian GNU/Linux 12 (bookworm)
  • Rust: 1.82.0
  • .NET: 9.0.100
  • Go: 1.23.3
  • Java: openjdk 23.0.1 build 23.0.1+11-39
  • Java (GraalVM): java 23.0.1 build 23.0.1+11-jvmci-b01
  • NodeJS: v23.2.0
  • Python: 3.13.0

如果可用,所有程序都使用发布模式启动,并且由于我们的测试环境中没有 libicu,国际化和全球化支持被禁用。

结果

最小内存占用

让我们从小规模开始,因为某些运行时本身就需要一些内存,我们先只启动一个任务。


我们可以看到 Rust、C#(NativeAOT) 和 Go 达到了类似的结果,因为它们都被静态编译成原生二进制文件,需要很少的内存。Java(GraalVM native-image) 也表现不错,但比其他静态编译的程序多用了一点内存。其他在托管平台上运行或通过解释器运行的程序消耗更多内存。

在这种情况下,Go似乎有最小的内存占用。

Java 使用 GraalVM 的结果有点出人意料,因为它比 OpenJDK 的 Java 消耗更多内存,但我猜这可以通过一些设置来调优。

1万个任务

这里有一些惊喜!两个 Rust 基准测试都取得了非常好的结果:即使后台运行着1万个任务,它们使用的内存也很少,与最小内存占用相比没有增长太多!C#(NativeAOT) 紧随其后,只使用了约 10MB 内存。我们需要更多任务来给它们施加压力!

Go 的内存消耗显著增加。goroutines 应该是非常轻量级的,但实际上它们消耗的 RAM 比 Rust 多得多。在这种情况下,Java(GraalVM native image) 中的虚拟线程似乎比 Go 中的 Goroutines 更轻量级。令我惊讶的是,Go 和Java(GraalVM native image) 这两个静态编译成原生二进制文件的程序,比运行在VM上的C#消耗更多RAM!

10万个任务

在我们将任务数量增加到 10 万后,所有语言的内存消耗开始显著增长。

Rust 和 C# 在这种情况下都表现得很好。一个大惊喜是 C#(NativeAOT) 甚至比 Rust 消耗更少的 RAM ,击败了所有其他语言。非常令人印象深刻!

在这一点上,Go 程序不仅被 Rust 击败,还被 Java(除了在 GraalVM 上运行的那个)、C# 和 NodeJS 击败。

100万个任务

现在让我们走个极端。

最终,C#毫无疑问地击败了所有其他语言;它非常有竞争力,真的成为了一个怪物。正如预期的那样,Rust在内存效率方面继续表现出色。

Go 与其他语言的差距进一步扩大。现在 Go 比冠军多消耗13倍以上的内存。它也比 Java 多消耗2倍以上,这与 JVM 是内存大户而 Go 轻量级的普遍认知相矛盾。

总结

正如我们观察到的,大量并发任务即使不执行复杂操作也会消耗大量内存。不同的语言运行时有不同的权衡,有些对少量任务来说轻量高效,但在处理数十万个任务时扩展性较差。

自去年以来,很多事情都发生了变化。通过对最新编译器和运行时的基准测试结果,我们看到 .NET 有了巨大的改进,使用 NativeAOT 的 .NET 真的能与 Rust 竞争。用 GraalVM 构建的 Java 原生镜像在内存效率方面也表现出色。然而,Go 的 goroutines 在资源消耗方面继续表现不佳。

版权信息

原作者:hez2010

译者:InCerry

原文链接:
https://hez2010.github.io/async-runtimes-benchmarks-2024/

【引言】天下武功,唯快不破。

本文讨论如何免费且以最快速度上架自己的作品。

作者以自己从零开始到提交发布审核一共俩小时的操作流程分享给大家作参考。

【1】立项选择

结论:元服务,单机,工具类(非游戏)

原因:单机类元服务不需要软著和备案,工具类软件恰好满足这个条件。

注意:要快速上架就不能做游戏类的,游戏版号难拿。

APP

(游戏)

APP

(非游戏)

元服务-联网

(游戏)

元服务-联网

(非游戏)

元服务-单机

(游戏)

元服务-单机

(非游戏)

备案 需要 需要 需要 需要 不需要 不需要
软著 需要 需要 不需要 不需要 不需要 不需要
游戏类版号 需要 不需要 需要 不需要 需要 不需要

【2】代码实现

结论:计数器

原因:我之前分享了几十个案例,找到一个不涉及网络的功能,不涉及游戏的,然后新建元服务项目后,把代码粘上去就能用了。

注意:感觉有游戏性质的,可以包装一下,比如舒尔特方格(注意力训练)巧算24点(儿童心算锻炼)

分享可能过审的案例源码 地址
温度转换 https://blog.csdn.net/zhongcongxu01/article/details/144066200
文字转拼音 https://blog.csdn.net/zhongcongxu01/article/details/144035280
亲戚关系计算器 https://blog.csdn.net/zhongcongxu01/article/details/144032594
二维码的生成与识别 https://blog.csdn.net/zhongcongxu01/article/details/144009716
字数统计 https://blog.csdn.net/zhongcongxu01/article/details/143978920
数字转中文大小写 https://blog.csdn.net/zhongcongxu01/article/details/143969470
血型遗传计算 https://blog.csdn.net/zhongcongxu01/article/details/143953240
简体繁体转换器 https://blog.csdn.net/zhongcongxu01/article/details/143933010
随机数生成 https://blog.csdn.net/zhongcongxu01/article/details/143912041
随机密码生成 https://blog.csdn.net/zhongcongxu01/article/details/143901826
计数器 https://blog.csdn.net/zhongcongxu01/article/details/143826840
年龄计算 https://blog.csdn.net/zhongcongxu01/article/details/143779166
光强仪 https://blog.csdn.net/zhongcongxu01/article/details/143744212
指尖轮盘 https://blog.csdn.net/zhongcongxu01/article/details/143692460
抛硬币 https://blog.csdn.net/zhongcongxu01/article/details/143670581
转盘 https://blog.csdn.net/zhongcongxu01/article/details/143654977
水平仪 https://blog.csdn.net/zhongcongxu01/article/details/143602499
七巧板 https://blog.csdn.net/zhongcongxu01/article/details/143584015
指南针 https://blog.csdn.net/zhongcongxu01/article/details/143474707
直尺 https://blog.csdn.net/zhongcongxu01/article/details/143466472
分贝仪 https://blog.csdn.net/zhongcongxu01/article/details/143460361
舒尔特方格 https://blog.csdn.net/zhongcongxu01/article/details/142746066
电子木鱼 https://blog.csdn.net/zhongcongxu01/article/details/143093410
垃圾分类 https://blog.csdn.net/zhongcongxu01/article/details/143229374
巧算24点 https://blog.csdn.net/zhongcongxu01/article/details/143311284

【3】确定应用名称

结论:去工信部查没备案的名称,我这边查找到“计数器”有5个备案,最终查到“日常计数器”没有被备案。

原因:已备案的名称,当你提审时,36小时后才会收到被拒通知【您的元服务与“计数器”的名称相同,但并未提供相关授权文件,不符合华为应用市场《元服务审核指南》】

注意:查询的时候记得勾选“APP”类型

查询地址:https://beian.miit.gov.cn/#/Integrated/recordQuery

【4】制作Logo

结论:准备1024*1024像素正方形图片,纯色或渐变,再写俩儿字。最后使用官方提供的Image Asset工具生成元服务logo

原因:咱是为了快速上架,一切从简哈。

注意:必须使用Image Asset生成logo,自己做个大差不差的会被检测出来,提审36小时后会被拒

【5】打包配置:多目标产物

结论:分别配置调试和发布的product,在build-profile.json5配置applyToProducts,通过切换运行product来切换签名的证书配置

原因:调试证书与发布证书,平时开发的时候自动使用调试证书,打包的时候要使用发布证书,每次切换操作都需要再次调整很不方便。

参考官方配置:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-V5/ide-customized-multi-targets-and-products-guides-V5

具体做法:

(1)defalut当做平时的自动调试证书,新建release配置发布证书信息

(2)配置完成后,在build-profile.json5下,进行如下配置。重点是"applyToProducts": [ "default","release" ]

{
  "app": {
    "signingConfigs": [
      {
        "name": "default",
        "type": "HarmonyOS",
        "material": {
          ...
        }
      },
      {
        "name": "release",
        "type": "HarmonyOS",
        "material": {
          ...
        }
      }
    ],
    "products": [
      {
        "name": "default",
        "signingConfig": "default",
        "compatibleSdkVersion": "5.0.0(12)",
        "runtimeOS": "HarmonyOS",
        "buildOption": {
          "strictMode": {
            "caseSensitiveCheck": true,
            "useNormalizedOHMUrl": true
          }
        }
      },
      {
        "name": "release",
        "signingConfig": "release",
        "compatibleSdkVersion": "5.0.0(12)",
        "runtimeOS": "HarmonyOS",
        "buildOption": {
          "strictMode": {
            "caseSensitiveCheck": true,
            "useNormalizedOHMUrl": true
          }
        }
      }
    ],
    "buildModeSet": [
      {
        "name": "debug",
      },
      {
        "name": "release"
      }
    ]
  },
  "modules": [
    {
      "name": "entry",
      "srcPath": "./entry",
      "targets": [
        { 
          "name": "default",
          "applyToProducts": [
            "default","release"
          ]
        },
      ]
    }
  ]
}

(3)平时测试时切换default,再运行或打包。上传应用商店时再切换为release后再打包就行了。

【6】隐私声明

结论:使用系统模板

原因:不需要自己想隐私声明如何编写,只需要在AppGallery Connect(应用市场)上传包的的时候,选择“协议服务”-->"新建协议"。

然后在版本信息下拦框中,选择这个协议即可。

【7】用户协议

结论:使用gitee,新建仓库,利用md文件编写用户协议

原因:省去购买域名搭建服务器存放用户协议的钱,而且gitee用手机端打开效果更好一些。

参考我的协议当做模板:https://gitee.com/zhong-congxu/User-Agreement-Counter

【8】其它信息

接下来就简单了

(1)勾选:“免责函”

(2)选择单机APP

(3)如果手里没平板(pad)没做适配,建议去掉勾选平板(默认是添加的),因为很可能因为平板适配问题又耽误三天。

其它信息按提示填就好了

【9】最后上传app包(记得用要切换配置的release版本)

注意事项1:上传应用市场的包需要是.app格式,也就是开发工具的(build-->build APP)

生成的文件在项目根目录的build里,不是entry下的build里

注意事项2:要勾选“测试和正式上架”,如果勾选了“仅测试”那么在版本信息中上传包的时候会发现找不到。

【10】默默祈祷、逢审必过,提交审核、点完收工!