标签 Java 下的文章

量子计算入门与Go模拟

本文永久链接 – https://tonybai.com/2024/12/11/simulate-quantum-computing-in-go

2019年,Google宣布实现”量子霸权”,声称其53量子比特的量子计算机完成了一个经典超级计算机需要1万年才能完成的计算任务。这一宣告在当时引发了广泛关注和热议。而在这个过程中,我们也看到了太多对量子计算的误解。有人将其想象成未来取代经典计算机的全能机器,认为它能以指数级速度解决所有计算问题;也有人认为量子计算只是一个遥不可及的科研概念,与实际应用毫无关联。

五年过去了,世界依然被经典计算机主宰,量子计算逐渐变成了“落魄网红”,淡出了公众视野。

如今,人们对量子计算机的印象似乎仅剩下那盏“豪华吊灯”:


量子计算机(图片来自网络)

作为领域局外人,我无法判断量子计算是否进入了技术成熟度曲线(Hype Cycle)的”泡沫破裂谷底期”。但现在的量子计算机在用途上与图形处理单元(GPU)很类似,都主要集中在特定领域的问题解决上,且在这些领域预期会展现出独特的优势

GPU主要设计用于图形渲染、图像处理等领域,后因其能够高效地处理并行计算任务,在机器学习模型训练和推理领域也取得了显著成功。类似地,量子计算机当前也专注于一些特定的应用领域,如量子模拟、优化问题和密码学等。它们在解决这些复杂问题时,理论上有可能实现远超经典计算机的性能。

但无论是GPU还是量子计算,目前看来都无法胜任经典计算机中通用处理器(CPU)所擅长的一般任务,这就决定了经典计算机势必仍将存在,并且是我们和GPU以及量子计算机彼此交流和互动的主要方式

从经典计算机的角度,GPU是其图形计算单元,经典计算机会将其擅长的任务分派到GPU上进行处理。按照这个思路,我们可以大胆地设想一种叫作QPU(Quantum Processing Unit)的量子处理单元,经典计算机会将量子计算擅长的计算任务分派到QPU上进行处理:

这样的计算机结构,对于程序员来说再熟悉不过了!

到这里,有人可能会问:那么大一个“豪华吊灯”,如何能变为一个小巧玲珑的QPU并放到我们常见的PC中呢?

回顾经典的通用计算机发展史,这种可能性还真的存在。你能想到80年前由冯·诺依曼主持设计的第一代存储程序的计算机(即冯·诺依曼机,现代计算机的原型)EDVAC(电子离散变量自动计算机,Electronic Discrete Variable Automatic Computer)有多大吗:


经典计算机EDVAC(图片来自网络)

这个庞然大物的算力可能还不如现在你手腕上带的智能手表。随着物理学、材料科学等前沿学科的突破,“豪华大吊灯”变成一块板卡也不是没有可能。

与经典计算机的融合意味着如今的开发人员依然可以使用熟悉的人机交互界面与量子计算机打交道,包括量子计算的编程。但要针对量子计算机编程,需要了解量子计算的一般原理,就像基于英伟达的CUDA进行GPU编程一样。

然而,目前的现实是量子计算机依然是“昂贵且稀缺的设备”,世界范围内只有巨头公司以及大型科研院所才拥有真实的量子计算机。但普通程序员仍然可以通过模拟器工具一窥其奥秘,理解其核心概念并为未来应用做好准备:


量子编程的层次结构

这些量子模拟器可以在经典计算机上模拟量子计算过程,让量子计算的学习和实验变得触手可及。在这篇文章中,我就和大家一起学习一下量子编程的基本概念和编程方法,并使用模拟器编写一些简单的量子计算程序。

1. 从经典计算到量子计算的认知跨越

要理解量子计算,我们需要先回顾经典计算的基本概念和抽象,然后建立起通向量子计算的认知桥梁。

1.1 经典计算机的基本抽象

在经典计算中,信息的基本单位是比特(bit),其值由两种电平状态来表示:

  • 低电平(0):通常表示为“0”或“低电压”,例如0伏特。
  • 高电平(1):通常表示为“1”或“高电压”,例如5伏特或3.3伏特。

即每个比特的值只能取0或1。这种电平的二元状态形成了数字电路的基础,使得计算机能够处理和存储信息。

比特的电平状态不仅用于计算,还用于信息的存储和传输。在存储器(如RAM)中,每个比特的位置对应于一个电平状态,通常通过电容或电感来保持电平。在数据传输中,信号的电平变化用于表示比特的流动,例如在串行或并行通信中。

1.1.1 比特与布尔逻辑

经典计算机使用比特进行所有信息处理。而布尔逻辑正是基于比特的逻辑运算,包括以下基本操作:

  • AND(与):仅当两个输入均为1时,输出才为1。
  • OR(或):只要有一个输入为1,输出即为1。
  • NOT(非):将输入的0变为1,1变为0。

这些基本逻辑门是构建更复杂运算的基础,所有复杂的计算都可以通过这些简单的逻辑操作组合而成。

1.1.2 门电路模型

门电路模型是经典计算的核心,利用逻辑门的连接来实现复杂的计算任务。电路由逻辑门(如与门、或门、非门等)构成,通过这些门的组合与连接,可以构建出加法器、乘法器等基本算术单元,以及更复杂的功能,比如处理器中的运算单元。门电路模型的优势在于其可组合性和可扩展性,使得计算机能够执行从简单到复杂的各种任务。

1.1.3 程序抽象

编程语言为程序员提供了更高级的抽象,使得比特操作不再需要直接进行,尤其是近半个世纪以来诞生的高级语言,如C/C++、Java、Go、Python等。通过这些高级编程语言,开发者可以使用更接近自然语言的语法来编写代码,底层的比特操作则由编译器或解释器自动处理。这种抽象不仅提高了编程效率,也使得程序员可以专注于算法和逻辑,而不必深入底层硬件细节。例如,C、Go、Python等编程语言提供了丰富的类型系统、控制结构与高级数据结构,使得程序员可以用简单的语句来处理复杂的数据操作。随着技术的发展,面向对象编程、函数式编程等新范式也相继出现,为软件开发提供了更灵活的方式。

以上对经典计算机的认知路径能够为理解量子计算机提供重要的基础和视角。接下来,我们就沿着这个路径认识一下量子计算的核心概念。

1.2 量子计算的核心概念

提及“量子”,人们首先想到的可能是大学物理中的量子力学。量子的概念来源于物理学,但和电子等真实存在的粒子不同,“量子”并不是指某个特定的粒子,而是一个广泛的基本概念,用来描述物质和能量在微观尺度上的离散性。在物理学中,量子具有以下主要特性:

  • 不确定性

海森堡的不确定性原理。该原理指出,某些物理量的精确值不能同时被完全确定,比如位置和动量。即使在理想情况下,测量一个量的精确性会导致对另一个量的测量不确定性增加。

  • 量子叠加态

在量子力学中,物体的状态被称为量子态。量子态可以通过波函数来描述,波函数包含了物体可能的所有状态的信息。此外,量子叠加原理允许一个量子系统同时处于多个状态。例如,电子可以同时占据多个能级,直到被测量时才“坍缩”到某个具体状态。(关于叠加态,后面详说)

  • 量子纠缠

量子纠缠是指两个或多个量子系统之间存在一种特殊的关联,使得对其中一个系统的测量会立即影响到另一个系统的状态,无论它们的距离有多远。量子纠缠是量子计算和量子通信的重要基础。

注:不要问我如何深入理解上述的特性,如果你对量子机制感兴趣,可以去读读费曼教授的物理学讲义,如果你能读懂的话:)。

而量子计算就是建构在量子的上述特性之上的。

1.2.1 量子比特(Qubit)

经典计算机中,信息和操作的基本单位是比特,在量子计算中,信息和操作的基本单位是量子比特(qubit)。不过与经典比特的确定的二元状态(0或1)不同,量子比特处于叠加态(Superposition)

要理解量子计算,首当其冲的就是理解什么是量子比特的叠加态。

注意!注意!烧脑内容即将来袭

学习大学物理时,估计大家都接触过量子力学的皮毛,可能让你印象最深的就是“薛定谔的猫(Schrödinger’s Cat)”!


图来自网络

这是奥地利著名物理学家薛定谔提出的一个思想实验,是指将一只猫关在装有少量镭和氰化物的密闭容器里。镭的衰变存在几率,如果镭发生衰变,会触发机关打碎装有氰化物的瓶子,猫就会死;如果镭不发生衰变,猫就存活。根据量子力学理论,由于放射性的镭处于衰变和没有衰变两种状态的叠加,猫就理应处于死猫和活猫的叠加状态。这只既死又活的猫就是所谓的“薛定谔的猫”。

我们的量子比特就好比那只“薛定谔的猫”,只不过它的状态不是“死”和“活”的叠加态,而是0和1的叠加态。要知道“薛定谔的猫”的最终状态,需要观察者。而要知道一个量子比特的最终状态,需要对其进行测量(measure)

这里有两个概念需要深入理解,一个是0和1的叠加态,另一个则是测量

经典计算机的比特的状态是确定性的,你设置为1,它就是1,你设置为0,它就是0。如果在其生命周期内,你不去修改它,它会一直保持其最初的状态。

但量子比特的状态却不是确定性的,而是概率性的,即量子比特是以概率的形式存在。不过要了解量子比特,我们需要先了解如何表示量子比特,就像我们在经典计算中用二进制数表示经典比特那样。

在量子计算领域,量子比特有两种表示法:狄拉克符号表示法(Dirac notation)和布洛克球(Bloch sphere)几何表示法,下面分别简单介绍一下。

1.2.2 狄拉克符号表示法

狄拉克符号是一种用于表示量子态的数学符号,也称为“凯特尔符号(ket notation)”,这个名称来源于单词“bracket”中的“bra”和“ket”。“Ket”指代右括号,用于量子态的表示,形式为|ψ⟩,其中ψ是量子态的名称或描述。而“Bra”是与“Ket”相对的符号,形式为⟨φ|,用于表示量子态的共轭转置。狄拉克符号的引入极大地方便了量子力学中的数学描述,使得量子态的表示更加简洁和直观。使用这些符号,物理学家可以轻松地进行状态的叠加、内积、外积等操作。

采用狄拉克符号的量子比特的通用状态,即叠加态的表示写法如下:

∣ψ⟩=α∣0⟩+β∣1⟩

其中|0⟩和|1⟩代表了量子比特的两个基本状态:

- |0⟩状态:称为基态(ground state)或零态(zero state)。这是量子比特的最低能量状态,对处于这个能量状态的量子比特进行测量时,它会坍缩为经典比特值0。
- |1⟩状态:称为激发态(excited state)或一态(one state)。这是量子比特的高能量状态,对处于这个能量状态的量子比特进行测量时,它会坍缩为经典比特值1。

而叠加态表示中的α和β是两个复数,且它们满足归一化条件,即它们模的平方和为1,表示在进行测量时,量子比特在状态|0⟩和|1⟩的概率总和为1:

|α|^2 + |β|^2 = 1

α和β也被称为|0⟩和|1⟩态的概率幅

而上面说的基态和激发态则是叠加态的两个特例:

∣ψ⟩= |0⟩ = α∣0⟩+β∣1⟩ = 1∣0⟩ + 0∣1⟩,即此时α=1,β=0;
∣ψ⟩= |1⟩ = α∣0⟩+β∣1⟩ = 0∣0⟩ + 1∣1⟩,即此时α=0,β=1;

还有一种量子比特的状态比较常用,那就是|0⟩状态量子比特通过Hadamard门(稍后会详细说明)生成的均匀叠加态量子比特。这个状态表示该量子比特在测量时有50%的概率得到|0⟩和50%的概率得到|1⟩。该量子比特可以表示为:

∣ψ⟩=(1/√2)|0⟩ + (1/√2)|1⟩

即α = β = 1/√2。而α、β这两个复数的值也可以推断一下,可以是:

α = 1/2 + (1/2)i
β = 1/2 - (1/2)i

也可以是:

α = 1/√2 + 0i
β = 1/√2 + 0i

它们都满足量子比特叠加态的归一化条件。

和任何计算对象一样,量子比特也有其图形化的表示法,即布洛克球。接下来,我们就来说明一下什么是布洛克球。

1.2.3 布洛克球几何表示法

布洛克球(如下图)是一种用于直观理解量子比特状态的表示法。


图来自《Quantum Computation and Quantum Information,10周年版》一书

量子比特的状态可以用球面上的点表示,通常使用极坐标。

如上图所示:

角度θ表示从正Z轴的倾斜角度(0到π),是布洛赫球上的纬度角,决定状态向量在z轴上的投影,描述了比特更接近|0⟩还是|1⟩。

角度ϕ表示在XY平面上的方位角(0到2π),是布洛赫球上的经度角,表示相对相位。不同的ϕ可能不会影响单独测量|0⟩或|1⟩的概率,但会影响量子态之间的干涉效应,因此对量子计算非常重要。

布洛克球的每个点代表一个量子比特的可能状态,其状态可以表示为下面公式:

球的北极(|0⟩)和南极(|1⟩)分别对应量子比特的基态和激发态,而球面上的其他点则表示叠加态。

1.2.4 测量(measure)

量子比特一直处于叠加态,其结果也是不确定的。但我们基于量子比特进行计算的目的是为了得到确定的结果,这就需要对量子比特进行测量。

测量是量子系统与经典系统之间的交互过程,通过这一过程,量子比特的叠加态将坍缩到一个确定的状态(基态0或激发态1)。

由前面的内容我们知道,量子比特的叠加状态是一种概率性的,在量子测量中,并没有一个固定的概率值来决定坍缩为基态或激发态,因此测量结果也是随机的。

测量结果为0的概率(基态)为P(0)=∣α∣^2,测量结果为1的概率(激发态)为:P(1)=∣β∣^2。

如果P(0) >= P(1),量子比特更有可能坍缩为|0⟩(经典比特值为0),反之,量子比特更有可能坍缩为|1⟩(经典比特值为1)。

测量将使得量子比特失去叠加状态,转变为经典状态。这意味着在测量之后量子比特的叠加态特性将不复存在了。

1.2.5 多个量子比特

在经典计算中,单个经典比特只能表示两个状态0和1,要表示更多状态需要多个经典比特。比如如果有两个经典比特,那么会有四种可能的状态:00、01、10和11。8个经典比特可以表示2^8个状态,以此类推。

在量子计算中,我们也可以将多个量子比特放到一起来表示组合状态,这种组合状态可以通过张量积表示和实现。

张量积(tensor product)是数学中一种组合两个向量空间的方法。对于两个复数向量空间A和B,它们的张量积A⊗B创建一个新的向量空间,表示两个空间的所有可能的“组合”。

对于量子比特而言,如果我们有两个量子比特的状态:∣ψ1⟩和|ψ2⟩,它们的组合状态,即张量积可以表示为:

∣ψ⟩ = ∣ψ1⟩⊗ |ψ2⟩

也可表示为|ψ1ψ2⟩ = ∣ψ1⟩⊗ |ψ2⟩

比如一个两量子比特的系统有四个计算基态,由每个量子比特的基态(|0⟩或|1⟩)组合而成,表示为|00⟩、|01⟩、|10⟩和|11⟩。

一对量子比特也可以存在于这四种状态的叠加中,这两个量子比特的量子叠加态|ψ⟩可以表示为下面公式:

|ψ⟩ = a|00⟩ + b|01⟩ + c|10⟩ + d|11⟩

即每种组合的计算基态的叠加,其中的a、b、c、d与前面的单个量子比特的基态的系数一样,都是一个复数,它们同样满足归一化条件,即它们模的平方和为1:

|a|^2 + |b|^2 + |c|^2 + |d|^2 = 1

两个量子比特的叠加态还有一个特殊的名字叫Bell态(Bell state)。由此类推,一个n量子比特的系统将可以表示2^n种状态的叠加,至于测量后会得到哪种状态,那就是随机的了,要看哪种状态的概率更大。

2. 量子门电路

抽象出量子比特的目的是为了运算,经典比特的运算由各种逻辑门电路实现,并通过门电路的组合实现更为强大和复杂的运算能力。量子比特的操作也是由量子门电路实现的。量子计算中的门电路是对量子比特进行操作的基本单元,其作用是对量子比特的状态进行变换。下面我们就来看看都有哪些常见的量子门电路。

2.1 单量子比特门

单量子比特门作用于一个量子比特,改变其状态。

  • Hadamard门(H门)

H门可以将量子比特从基态(|0⟩或|1⟩)转变为均匀叠加态,常用于初始化叠加态,是许多量子算法(如 Grover 和 Shor 算法)的基础:

对于基态|0⟩运用H门:

H|0⟩ = (1/√2)|0⟩ + (1/√2)|1⟩ 

对于基态|1⟩运用H门:

H|1⟩ = (1/√2)|0⟩ - (1/√2)|1⟩
  • Pauli-X门

类似经典计算中的NOT门,可以实现|0⟩和|1⟩的交换,即将|0⟩变为|1⟩,|1⟩变为|0⟩:

X∣0⟩=∣1⟩
X∣1⟩=∣0⟩
  • Pauli-Y门

Pauli-Y门不仅可以像Pauli-X门那样翻转比特的状态,还引入了一个相位因子:

Y∣0⟩=i∣1⟩
Y∣1⟩=−i∣0⟩
  • Pauli-Z门

Pauli-Z门主要负责相位反转。它不会改变|0⟩状态,但会对|1⟩状态施加相位反转。它在量子信息处理中用于引入相位差:

Z∣0⟩=∣0⟩
Z∣1⟩=−∣1⟩
  • S门(相位门)

S门,也称为相位门,能够对量子比特状态施加特定的相位。它只影响|1⟩态的相位,在|1⟩态上施加相位π/2(即i),而不改变|0⟩态。

S∣0⟩=∣0⟩
S∣1⟩=i∣1⟩
  • T门

T门,也称为四分之一相位门,类似于S门,但施加的相位为π/4,即|1⟩态上施加相位π/4,而对|0⟩态没有影响:

T∣0⟩=∣0⟩
T∣1⟩=e^(i*π/4)∣1⟩

S门和T门可以组合使用,形成更复杂的相位操作。例如,S门可以被看作是T门的平方。这些相位门在量子计算中具有重要作用,尤其是在量子态的干涉和量子算法(如量子傅里叶变换)中。

量子态的干涉是量子力学中的一个核心现象,类似于经典波动中的干涉现象。它描述了不同量子态之间相位关系的作用,导致量子态的概率幅相加或相消,从而影响测量结果。

干涉现象可以分为两种类型:

  • 加强干涉(Constructive Interference):当两种或多种量子态的概率幅相位相同或相近时,它们会相互叠加,增强某个测量结果的概率。例如,如果两个量子态均为∣1⟩,则它们的概率幅相加,导致更高的测量概率。
  • 削弱干涉(Destructive Interference):当不同量子态的概率幅相位相反时,它们会互相抵消,降低某个测量结果的概率。例如,如果一个状态为∣0⟩,另一个状态为∣1⟩的相位相反,则它们的叠加会导致测量结果的概率减少。

量子计算中常见的两种算法:Shor算法Grover算法就是利用量子干涉来提高计算效率的。

2.2 双量子比特门

双量子比特门是量子计算中用于处理两个量子比特之间关系的基本操作。这些门能够实现量子比特之间的纠缠(关于这个概念稍后再说)和相互作用,是量子计算的重要组成部分。下面介绍几种常见的双量子比特门:

  • CNOT门(受控-NOT门)

CNOT门(Controlled-NOT Gate)是最常用的双量子比特门之一。它对一个量子比特(目标比特)施加NOT操作,前提是另一个量子比特(控制比特)处于|1⟩状态,具体表现如下(第一个量子比特为控制比特,第二个量子比特为目标比特):

- 输入状态|00⟩,变为 |00⟩
- 输入状态|01⟩,变为 |01⟩
- 输入状态|10⟩,变为 |11⟩
- 输入状态|11⟩,变为 |10⟩

CNOT门能够创建量子比特之间的纠缠,是量子计算中实现量子算法的基础。

  • CZ门(受控-Z门)

CZ门是另一种重要的双量子比特门。它对目标量子比特施加Z门(相位反转)操作,前提是控制量子比特(第一个量子比特)的状态为|1⟩。其具体表现如下:

- 输入状态|00⟩, 变为|00⟩
- 输入状态|01⟩, 变为|01⟩
- 输入状态|10⟩, 变为|10⟩
- 输入状态|11⟩, 变为-|11⟩(施加相位反转)

CZ门用于引入相位关系,也常用于量子纠缠和量子算法中。

  • SWAP门

SWAP门是一种双量子比特门,它的主要功能是交换两个量子比特的状态。具体来说,如果有两个量子比特A和B,SWAP门会将它们的状态互换。

给定输入状态|AB⟩,SWAP门的作用如下:

|00⟩ → |00⟩
|01⟩ → |10⟩
|10⟩ → |01⟩
|11⟩ → |11⟩

我们看到:SWAP门将|0⟩和|1⟩的状态互换,而|00⟩和|11⟩保持不变。

在量子通信中,SWAP门可以用于在不同的量子比特之间交换信息。在量子电路中,SWAP门可以用来重排量子比特的位置,以实现特定的逻辑操作。

接下来,我们再来看看常用的多量子比特门,即三个或三个以上量子比特的门电路。

2.3 多量子比特门

  • Toffoli门(CCNOT门)

Toffoli门是一个三量子比特门,只有在前两个量子比特均为|1⟩时,才对第三个量子比特施加NOT操作。其具体行为表现如下:

- 输入状态|000⟩, 变为|000⟩
- 输入状态|001⟩, 变为|001⟩
- 输入状态|010⟩, 变为|010⟩
- 输入状态|011⟩, 变为|011⟩
- 输入状态|100⟩, 变为|100⟩
- 输入状态|101⟩, 变为|101⟩
- 输入状态|110⟩, 变为|111⟩
- 输入状态|111⟩, 变为|110⟩

Toffoli门也是经典计算的量子对应物,能够实现复杂的逻辑操作,常用于量子纠错和量子算法中。

  • CSWAP门(受控-SWAP门)

CSWAP门是一种受控的操作双量子的比特门,但因为有一个额外的控制量子比特,因此将其纳入多量子比特门一类。和SWAP门无条件交换两个量子比特状态不同,CSWAP门只有在控制量子比特处于|1⟩状态时,才会交换目标量子比特的状态。它可以看作是SWAP门的受控版本。

给定输入状态|CAB⟩,其中C为控制比特,A和B为目标比特,CSWAP门的作用如下:

当C = |0⟩时,|CAB⟩保持不变。
当C = |1⟩时:
    |101⟩ → |110⟩
    |100⟩ → |101⟩
    |011⟩ → |011⟩
    |010⟩ → |010⟩

2.4 特殊组合门

和经典计算的门电路组合一样,通过对上面量子门的组合,我们可以得到一些非常实用的门电路,其中贝尔态门(Bell State Gate)就是一种非常常用的量子门,它的主要功能是将两个量子比特从一个未纠缠的状态转换为一个纠缠状态。常见的实现方式是通过组合Hadamard门和CNOT门来生成贝尔态。贝尔态在量子通信、量子密码学和量子纠缠研究中都有着重要应用。

假设初始态是两个量子比特的分离态∣ψ⟩=∣00⟩,以下步骤可以生成Bell态:

  • 对第一个量子比特应用H门:
H∣0⟩= 1/√2(∣0⟩+∣1⟩)

这之后整体状态变为:

|ψ⟩= 1/√2(∣0⟩+∣1⟩)∣0⟩ = 1/√2(∣00⟩+∣10⟩)
  • 继续应用CNOT门(控制比特为第一个比特,目标比特为第二个比特)

如果控制比特是∣0⟩,目标比特保持不变。

如果控制比特是∣1⟩,目标比特翻转。

CNOT门作用后,状态变为:

|ψ⟩= 1/√2(∣00⟩+∣11⟩)

这就是Bell态!

2.5 量子纠缠态

好,到这里也该说一说之前提到的量子纠缠态了。

一提到量子纠缠,人们通常会想到它的神秘性和非经典特性,尤其是关于量子之间的瞬时关联和信息传递的潜能。这种现象挑战了经典物理的直觉,常常引发人们对量子计算、量子通信和量子隐形传态等前沿技术的兴趣与讨论。同时,量子纠缠也引发了关于量子力学基础的哲学思考,例如关于现实、因果关系和信息本质的深层次问题。

通过前面的学习,我们知道每个量子比特都有自己的状态,可以是初始基态∣0⟩或|1⟩,也可以是叠加态∣ψ⟩=α∣0⟩+β∣1⟩。我们还可以将多个量子比特的状态合并成一个更高维度的复合量子态,这种组合状态可以通过张量积表示和实现。

在量子计算中,量子纠缠也是一种量子态,但其中系统的整体状态无法写成各部分状态的简单张量积。例如,如果两个粒子的状态∣ψ⟩是纠缠态,则意味着我们无法将它分解为如下形式:

∣ψ⟩ =∣ψ1⟩⊗ ∣ψ2⟩ // 这里∣ψ1⟩和∣ψ2⟩分别是量子比特1和量子比特2的单独态。

纠缠态的独特之处在于以下两点:

  • 非局域性:两个粒子即使相距遥远,其状态仍然以某种方式相互关联。
  • 测量相关性:对其中一个粒子的测量结果会即时影响另一个粒子的测量结果。

以上面形成纠缠态的Bell态为例:

|ψ⟩= 1/√2(∣00⟩+∣11⟩)

量子纠缠使得两个比特的状态紧密关联,它表示系统有50%的概率处于∣00⟩,有50%的概率处于|11⟩。

在量子计算中,测量是一个不可逆的过程,它会强制系统状态从叠加态塌缩到与测量结果一致的确定态。在Bell状态下,纠缠的非局域性保证了两个比特始终保持一致,无论测量顺序如何。

测量第一个量子比特后,如果得到的结果是∣0⟩,则整个系统的状态立即塌缩为|00⟩,则第二个量子比特的测量结果也必定是∣0⟩。如果测量第一个量子比特的结果是|1⟩,则整个系统的状态立即塌缩为11⟩,则第二个量子比特的测量结果也必定是∣1⟩。

量子纠缠的测量相关性没有经典的对应物,但可以用下面这个隐喻来帮助理解:

  • 想象一对手套,左手套和右手套随机装进两个盒子,分别送到两个人手中。
  • 如果一个人打开盒子发现是左手套,他立刻知道另一个人拿到的是右手套。

只是与经典类比不同的是,量子纠缠中没有“预先分配”的状态,测量本身创造了这种确定性。

在真实的量子计算机中,量子纠缠已经得到了实现,并且被广泛用作验证量子计算机性能的基础实验之一。通过操纵量子比特,我们能够在单一量子计算机上生成并观察到纠缠态的特性。但这仅限于单台量子计算机中的两个量子比特。

而在远距离的两个量子之间建立纠缠,这是量子通信的核心研究目标。据公开报道,中国科学家已通过光子实现了远距离纠缠分发。中国“墨子号”量子科学实验卫星成功在1200公里的距离上分发了纠缠态光子对,这是量子通信中“量子纠缠分发”的成功案例。

到这里,我们已经介绍了量子计算的常见各种量子比特门电路,而基于上述量子比特门电路来解决经典计算难以高效解决的问题的算法,就被称为量子算法。下面我们再简单介绍一下量子算法的特性以及有哪些常见的量子算法。

3. 量子算法

由于量子比特的“超出经典直觉”的性质,相对于经典计算中的算法,量子算法的理解路径更为“崎岖”,有时候大脑需要一些“天马行空”。

我们以量子计算的经典入门示例算法:多伊奇-乔萨算法为起点,来看一下量子计算算法的一般特点和设计模式。

多伊奇-乔萨算法(Deutsch–Jozsa algorithm)是戴维·多伊奇和理查德·乔萨于1992年提出的一种确定性量子算法,该算法展示了量子算法在特定问题上指数级加速的能力,以及量子算法设计的一般模式。下面我们就来介绍一下该算法。

3.1 Deutsch–Jozsa algorithm

《量子计算和量子信息》一书在介绍这个算法时,使用了一个Alice和Bob的故事来解释,这里我们也借鉴一下,希望能更好的帮助大家理解其核心概念和量子计算的优势。

话说Alice和Bob是一对喜欢挑战逻辑问题的好朋友。某一天,Alice设计了一个“神秘黑箱”(即函数f(x)),可以接收n位二进制输入(例如x = 000, 101, 111),并输出一个二进制结果(0或1)。

但是,Alice做了一些特殊限制:

  • 要么黑箱的输出在所有可能输入上是完全一致的,即f(x)恒为0或1,称为“常值”。
  • 要么黑箱的输出在所有可能输入上是均匀分布的,即对一半输入,f(x) = 0,对另一半输入,f(x) = 1,称为“平衡”。

Alice给Bob的任务是:确定这个黑箱到底是常值还是平衡的

Bob起初考虑使用经典计算机来完成这个任务。他每次输入一个值x,黑箱会返回f(x)。为了判断f(x)是“常值”还是“平衡”,他必须测试多个x:

  • 如果他输入所有2^n个可能值,看到f(x)恒为0或1,他可以确定黑箱是常值。
  • 如果f(x)的输出在2^n个输入中有一半是0,一半是1,他就知道它是平衡的。

但问题是:最坏情况下,Bob需要检查2^(n-1) + 1次(即超过一半的输入),才能确定黑箱的性质!当n很大时(例如n = 100),需要有2^99+1次输入。这样的解决方案,经典计算机无法胜任,显然也会被Alice鄙视。

于是Bob想到用量子计算机来解决这个问题。他发现量子计算可以一次性对所有2^n个输入进行并行处理,并通过量子叠加和干涉提取答案。以下是他用量子计算的步骤:

  • 初始化

Bob将他的量子计算机初始化到以下起始状态:

- n个量子比特(输入),全部设置为基态∣0⟩
- 一个辅助比特(输出),设置为∣1⟩。

按照之前的量子比特联合态,我们可以得出当前初始状态为:

∣0⟩^⊗n ⊗ |1⟩

如果n=2,那么初始状态即为|00⟩|1⟩。

  • 进入叠加态

Bob对所有输入比特(包括辅助比特)施加Hadamard门(H门),使它们进入叠加态:

这一操作使得所有量子比特都进入叠加态,为后续的量子计算步骤奠定了基础。通过这种方式,算法能够有效地并行处理多个输入状态,尝试所有可能性。

  • 黑箱作用

Alice的黑箱被量子化,成为一个量子门,即用一个量子门来实现Alice的神秘黑箱f(x),它将输入态变换为下面状态:

∣x⟩∣y⟩ -> ∣x⟩|y ⊕ f(x)⟩

- ∣x⟩是工作寄存器,表示输入x
- ∣y⟩是辅助寄存器,用于存储函数的输出
-  ⊕ 表示模2加法(XOR),可以用CNOT门实现。

这个操作会根据f(x)的值改变辅助比特的状态(增加一个相位因子)。

f(x)也称为量子计算中的oracle函数,在真实的量子计算中,Oracle 函数通常是根据具体的算法和问题,通过量子门操作实现的。Deutsch–Josza Algorithm 在理论上描述了Oracle函数的行为,但在真实的量子计算中,需要具体设计量子电路来实现该函数。

几乎所有量子算法都有自己的oracle函数,它是量子算法的核心部分。通过精心设计Oracle函数,可以将量子计算应用于各种实际问题,包括优化、搜索和密码分析等领域。

  • 量子干涉(第二次应用H门)

在这一阶段,我们对前n个量子比特再次应用Hadamard门。这个步骤是干涉效应的关键,利用量子干涉增强他所需的信息。

如果当前状态为∣x⟩|y ⊕ f(x)⟩,应用H门后,前n个量子比特的状态会变为:

在应用Hadamard门后,整个量子态变为:

通过数学推导,结果可以证明:如果f(x)是常值,则只有∣x⟩^⊗n的概率幅为非零。如果f(x)是平衡的,则所有其他态的概率幅抵消,只留下非∣x⟩^⊗n的态。

  • 测量

最后,Bob测量输入比特的状态:如果结果是∣0⟩^⊗n,则得出f(x)是常值;如果结果不是∣0⟩^⊗n ,则f(x)是平衡的。

关键是:Bob只需要一次调用黑箱即可完成判断,而不是经典算法的2^(n-1) + 1次调用。

这个算法也代表了量子算法的一般模式,大致都是这样的:

  • 量子叠加:创建所有输入的可能性。
  • 量子黑箱操作:使用Oracle函数,编码问题的特性。
  • 量子干涉:通过相位和概率幅操控,筛选目标答案。
  • 测量:提取结果。

在上面Bob和Alice的故事中,我们提到了量子并行计算,这也是Bob只需要一次调用黑箱即可完成判断的原因,那到底什么是量子并行计算呢?我们继续往下看。

3.2 量子并行

如果说理解叠加态,我们还有概率这个熟知的经典概念可以利用。在理解量子并行性上,我们的大脑只能“天马行空”了。

巧的是,量子并行性的概念也是由David Deutsch(上面多伊奇-乔萨算法的作者)于1985年在一篇开创性论文”Quantum theory, the Church–Turing principle and the universal quantum computer“中首次提出的,并由David Deutsch、Richard Jozsa和Artur Ekert后续进一步发展。普林斯顿大学官网可以免费下载这篇论文

不过即便是近四十年后的今天,对于量子并行这种概念的理解可能还很模糊。在经典计算中,实现并行计算理解起来非常直接,如果我有n个处理器,理论上我就可以在这n个处理器上同时执行n个不同的task。

但在量子计算中,量子化后的Oracle函数究竟是如何“并行处理”n个量子比特的呢?在今年的一篇来自瑞典皇家理工学院Stefano Markidis的预印版论文“What is Quantum Parallelism, Anyhow?”的结论中,作者如是说:

值得注意的是,休·埃弗雷特的多世界解释和多元宇宙假说是最直观和优雅的解释之一(wallace2012emergent;deutsch1998fabric)。根据这一解释,时间被设想为一棵多分支的树,每一个量子并行性的可能结果都在一个单独的分支或宇宙中实现。这一解释表明,每个计算路径在不同的现实分支中同时存在。这一概念与量子并行性的观念相一致,暗示所有潜在的量子计算结果在多个宇宙中并行发生。多元宇宙理论的一个重要含义是,它能够支持超越可观测宇宙中粒子数量(>300个量子比特)的量子并行性。在这样的情况下,多个平行宇宙可以同时容纳在不同分支上展开的计算过程,而不受单一宇宙中的粒子数量限制(deutsch1998fabric)。

好吧!我刚好看完科幻美剧“人生复本”以及同名原著,对上述多重平行宇宙有了一个感性且直观的认知:)。

如果你相信上述引述中的解释,那么所谓量子并行,只是多元宇宙都独立对输入做了一次计算,而对于当前的现实来看,这看起来就是一种“并行”,我们将每个宇宙当做一个“处理器”了!

Stefano Markidis认为:量子并行本质上是量子态相互作用产生的干涉图样:每个量子态都有助于形成集体干涉图样,类似于天线阵列中信号的组合。然而,与多个独立进程同时运行的经典并行不同,量子并行的特点是复杂的干扰网。减少量子并行性的概念强调了量子算法中相长干涉和相消干涉之间的微妙平衡。虽然相长干扰会放大某些计算路径,但相消干扰会选择性地抑制其他计算路径,最终引导系统找到正确的解决方案。并且,传统的量子应用算法通常会经历一个初始阶段,其中它们利用所有可用的并行性、叠加计算、操纵阶段并执行测量。在此初始阶段,量子算法最大化并行性。然而,由于干扰现象,随着应用的进展,并行性会减弱,从而降低了量子并行性。也就是说在任何量子算法中,从经典输入状态产生量子并行性的机制以及减少数据并行性以识别正确答案的机制都是必不可少的。


图来自Stefano Markidis的论文,用天线阵列信号组合的集体干涉来阐释量子并行

不管你是否理解了,或理解了多少,我们都要向下进行了。我们来看看如何用模拟器来实现上面的Deutsch–Jozsa algorithm。

4. Go语言实现量子计算模拟

对于量子计算初学者而言,量子编程语言和模拟器就像是通往量子世界的入口和学习工具。

目前一些大厂提供了专门的量子编程语言以及模拟器甚至量子计算硬件(或者虚拟机)来帮助开发者了解量子计算。主流的语言包括IBM开发的Qiskit(python)、Google的Ciq(python)、微软的Q#(C#)等。量子模拟器方面,IBM Quantum Experience在线量子计算平台、Google Cirq Simulator以及Microsoft Azure Quantum等实验环境,开发者都可以以免费或较低的代价试用和使用。

不过这里我打算用一个知名度没那么高的Go实现的量子计算模拟器,它就是github.com/itsubaki/q这个Go语言量子计算模拟器项目。q是一个用Go语言实现的开源量子计算模拟器,无外部依赖,支持基本的量子逻辑门以及测量,提供了多种量子比特操作以及量子态概率幅计算,适合Go开发者量子计算入门时使用。不过它没有对应的量子计算硬件或虚拟机,无法对接上面提到的大厂的量子计算实验环境。

q项目有两个关键组件,一个是q.Q,这是量子模拟器核心结构,是模拟器的抽象;另外一个则是q.Qubit,是量子比特抽象。下面我们就用q来模拟一下Deutsch–Jozsa algorithm:

// quantum-simulate/deutsch–jozsa/main.go

package main

import (
    "fmt"

    "github.com/itsubaki/q"
)

// 实现常数函数的oracle
func constantOracle(qsim *q.Q, controls []q.Qubit, target q.Qubit) {
    // 对于常数函数,这里什么都不做
    // 因为常数函数要么总是返回0,要么总是返回1
}

// 实现平衡函数的oracle
func balancedOracle(qsim *q.Q, controls []q.Qubit, target q.Qubit) {
    // 对于平衡函数,我们对输入做CNOT操作
    for _, control := range controls {
        qsim.CNOT(control, target)
    }
}

func deutschJozsa(n int, isConstant bool) string {
    // 创建量子模拟器
    qsim := q.New()

    // 创建n+1个量子比特的寄存器
    // n个输入比特加1个输出比特
    qreg := make([]q.Qubit, n+1)
    for i := range qreg {
        qreg[i] = qsim.Zero()
    }

    // 将输出比特置为|1>
    qsim.X(qreg[n])

    // 对所有量子比特应用H门
    for _, qubit := range qreg {
        qsim.H(qubit)
    }

    // 应用oracle
    if isConstant {
        constantOracle(qsim, qreg[:n], qreg[n])
    } else {
        balancedOracle(qsim, qreg[:n], qreg[n])
    }

    // 对输入寄存器应用H门
    for i := 0; i < n; i++ {
        qsim.H(qreg[i])
    }

    // 测量输入寄存器
    result := ""
    for i := 0; i < n; i++ {
        m := qsim.Measure(qreg[i])
        result += m.BinaryString()
    }

    return result
}

func main() {
    // 测试5个输入比特的情况
    n := 5

    // 测试常数函数
    resultConstant := deutschJozsa(n, true)
    fmt.Printf("常数函数的结果: %v\n", resultConstant)
    if resultConstant == "00000" {
        fmt.Println("检测到常数函数!")
    }

    // 测试平衡函数
    resultBalanced := deutschJozsa(n, false)
    fmt.Printf("平衡函数的结果: %v\n", resultBalanced)
    if resultBalanced != "00000" {
        fmt.Println("检测到平衡函数!")
    }
}

前面的量子计算理论知识有助于你理解这段代码,这里简单解释一下。

这段代码模拟实现了Deutsch–Jozsa algorithm,并分别进行了两次不同实现的Oracle函数查询,当然正常的算法只需查询一次即可,这里是为了模拟演示两种不同的情况。

代码首先进行了量子寄存器初始化:创建了n+1个量子比特:n个输入比特和1个输出比特,初始状态都是|0>:

qreg := make([]q.Qubit, n+1)
for i := range qreg {
    qreg[i] = qsim.Zero()
}

然后对输出比特应用Pauli-X门进行翻转,将其转换为|1>状态:

qsim.X(qreg[n])

对所有量子比特应用Hadamard门,将输入比特和输出比特置于叠加态,目的是同时”探测”所有可能的输入组合:

for _, qubit := range qreg {
    qsim.H(qreg[i])
}

查询Oracle函数进行量子并行计算:

if isConstant {
    constantOracle(qsim, qreg[:n], qreg[n])
} else {
    balancedOracle(qsim, qreg[:n], qreg[n])
}

对输入寄存器再次应用H门进行量子干涉:

for i := 0; i < n; i++ {
    qsim.H(qreg[i])
}

测量输入寄存器,根据测量结果判断函数类型:

// 测量输入寄存器
result := ""
for i := 0; i < n; i++ {
    m := qsim.Measure(qreg[i])
    result += m.BinaryString()
}

运行这个程序,你应该能看到如下输出:

$go run main.go
常数函数的结果: 00000
检测到常数函数!
平衡函数的结果: 11111
检测到平衡函数!

注意:我们使用经典计算模拟量子计算,oracle函数并不存在真正的量子并行,从代码也可以看出,是用for循环对每个量子比特顺序处理了一遍来模拟量子并行。

5. 小结

本文探讨了量子计算的基本概念及其与经典计算的关系,包括回顾了经典计算机的基本原理,包括比特、布尔逻辑和门电路模型,并引入了量子计算的核心概念,如量子比特(qubit)、叠加态、量子纠缠等。量子比特的叠加态使其能够同时表示多种状态,显著提升了计算能力。

文章后半部分还以Deutsch–Jozsa这个入门级量子算法为例,介绍了量子算法以及算法的一般模式。并使用github.com/itsubaki/q这个Go语言量子计算模拟器项目模拟实现了该算法,以帮助大家理解。

不过相对于经典计算算法,量子计算算法在理解上依然有很高门槛,文中仅仅以Deutsch–Jozsa这个入门级量子算法举例,像Grover’s search algorithm、Shor’s factoring algorithm等常见的实用量子算法理解起来更有难度。

就目前量子计算机的发展来看,尽管量子计算展现出在特定领域的潜力,但目前对经典计算领域的影响依然不大,更别提无法全面取代经典计算机了。对于普通开发人员来说,可以逐步理解量子计算的概念、思路、算法和编程范式,为以后量子计算成熟打好基础。

不过,量子计算的指数级加速算力能力已经被证实,在密码学领域,科研人员已经发布了可以抵御量子计算破解的密码算法,比如: ML-KEM(之前称为Kyber)德根。这些密码学算法被统称为“后量子密码学算法(Post Quantum Cryptography)”。Go密码学团队已经在标准库中给出了ML-KEM的实现,预计将在Go 1.24版本落地。

本文是我个人学习量子计算的笔记,内容源自我查阅的大量书籍,同时也结合了AI大模型的辅助。由于这是我第一次学习量子计算,加之该领域的内容相对抽象且复杂,因此笔记中可能存在一些错误,敬请谅解。

本文涉及的源码可以在这里下载。

6. 参考资料


Gopher部落知识星球在2024年将继续致力于打造一个高品质的Go语言学习和交流平台。我们将继续提供优质的Go技术文章首发和阅读体验。同时,我们也会加强代码质量和最佳实践的分享,包括如何编写简洁、可读、可测试的Go代码。此外,我们还会加强星友之间的交流和互动。欢迎大家踊跃提问,分享心得,讨论技术。我会在第一时间进行解答和交流。我衷心希望Gopher部落可以成为大家学习、进步、交流的港湾。让我相聚在Gopher部落,享受coding的快乐! 欢迎大家踊跃加入!

img{512x368}
img{512x368}

img{512x368}
img{512x368}

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻) – https://gopherdaily.tonybai.com

我的联系方式:

  • 微博(暂不可用):https://weibo.com/bigwhite20xx
  • 微博2:https://weibo.com/u/6484441286
  • 博客:tonybai.com
  • github: https://github.com/bigwhite
  • Gopher Daily归档 – https://github.com/bigwhite/gopherdaily
  • Gopher Daily Feed订阅 – https://gopherdaily.tonybai.com/feed

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

惊!Go在十亿次循环和百万任务中表现不如Java,究竟为何?

本文永久链接 – https://tonybai.com/2024/12/02/why-go-sucks

编程语言比较的话题总是能吸引程序员的眼球!

近期外网的两篇编程语言对比的文章在国内程序员圈里引起热议。一篇是由Ben Dicken (@BenjDicken) 做的语言性能测试,对比了十多种主流语言在执行10亿次循环(一个双层循环:1万 * 10 万)的速度;另一篇则是一个名为hez2010的开发者做的内存开销测试,对比了多种语言在处理百万任务时的内存开销。

下面是这两项测试的结果示意图:


10亿循环测试结果


百万任务内存开销测试结果

我们看到:在这两项测试中,Go的表现不仅远不及NonGC的C/Rust,甚至还落后于Java,尤其是在内存开销测试中,Go的内存使用显著高于以“吃内存”著称的Java。这一结果让许多开发者感到意外,因为Go通常被认为是轻量级的语言,然而实际的测试结果却揭示了其在高并发场景下的“内存效率不足”。

那么究竟为何在这两项测试中,Go的表现都不及预期呢?在这篇文章中,我将探讨可能的原因,以供大家参考。

我们先从十亿次循环测试开始。

1. 循环测试跑的慢,都因编译器优化还不够

下面是作者给出的Go测试程序

// why-go-sucks/billion-loops/go/code.go 

package main

import (
    "fmt"
    "math/rand"
    "os"
    "strconv"
)

func main() {
    input, e := strconv.Atoi(os.Args[1]) // Get an input number from the command line
    if e != nil {
        panic(e)
    }
    u := int32(input)
    r := int32(rand.Intn(10000))        // Get a random number 0 <= r < 10k
    var a [10000]int32                  // Array of 10k elements initialized to 0
    for i := int32(0); i < 10000; i++ { // 10k outer loop iterations
        for j := int32(0); j < 100000; j++ { // 100k inner loop iterations, per outer loop iteration
            a[i] = a[i] + j%u // Simple sum
        }
        a[i] += r // Add a random value to each element in array
    }
    fmt.Println(a[r]) // Print out a single element from the array
}

这段代码通过命令行参数获取一个整数,然后生成一个随机数,接着通过两层循环对一个数组的每个元素进行累加,最终输出该数组中以随机数为下标对应的数组元素的值。

我们再来看一下”竞争对手”的测试代码。C测试代码如下:

// why-go-sucks/billion-loops/c/code.c

#include "stdio.h"
#include "stdlib.h"
#include "stdint.h"

int main (int argc, char** argv) {
  int u = atoi(argv[1]);               // Get an input number from the command line
  int r = rand() % 10000;              // Get a random integer 0 <= r < 10k
  int32_t a[10000] = {0};              // Array of 10k elements initialized to 0
  for (int i = 0; i < 10000; i++) {    // 10k outer loop iterations
    for (int j = 0; j < 100000; j++) { // 100k inner loop iterations, per outer loop iteration
      a[i] = a[i] + j%u;               // Simple sum
    }
    a[i] += r;                         // Add a random value to each element in array
  }
  printf("%d\n", a[r]);                // Print out a single element from the array
}

下面是Java的测试代码:

// why-go-sucks/billion-loops/java/code.java

package jvm;

import java.util.Random;

public class code {

    public static void main(String[] args) {
        var u = Integer.parseInt(args[0]); // Get an input number from the command line
        var r = new Random().nextInt(10000); // Get a random number 0 <= r < 10k
        var a = new int[10000]; // Array of 10k elements initialized to 0
        for (var i = 0; i < 10000; i++) { // 10k outer loop iterations
            for (var j = 0; j < 100000; j++) { // 100k inner loop iterations, per outer loop iteration
                a[i] = a[i] + j % u; // Simple sum
            }
            a[i] += r; // Add a random value to each element in array
        }
        System.out.println(a[r]); // Print out a single element from the array
    }
}

你可能不熟悉C或Java,但从代码的形式上来看,C、Java与Go的代码确实处于“同等条件”。这不仅意味着它们在相同的硬件和软件环境中运行,更包括它们采用了相同的计算逻辑和算法,以及一致的输入参数处理等方面的相似性。

为了确认一下原作者的测试结果,我在一台阿里云ECS上(amd64,8c32g,CentOS 7.9)对上面三个程序进行了测试(使用time命令测量计算耗时),得到一个基线结果。我的环境下,C、Java和Go的编译器版本如下:

$go version
go version go1.23.0 linux/amd64

$java -version
openjdk version "17.0.9" 2023-10-17 LTS
OpenJDK Runtime Environment Zulu17.46+19-CA (build 17.0.9+8-LTS)
OpenJDK 64-Bit Server VM Zulu17.46+19-CA (build 17.0.9+8-LTS, mixed mode, sharing)

$gcc -v
使用内建 specs。
COLLECT_GCC=gcc
COLLECT_LTO_WRAPPER=/usr/libexec/gcc/x86_64-redhat-linux/4.8.5/lto-wrapper
目标:x86_64-redhat-linux
配置为:../configure --prefix=/usr --mandir=/usr/share/man --infodir=/usr/share/info --with-bugurl=http://bugzilla.redhat.com/bugzilla --enable-bootstrap --enable-shared --enable-threads=posix --enable-checking=release --with-system-zlib --enable-__cxa_atexit --disable-libunwind-exceptions --enable-gnu-unique-object --enable-linker-build-id --with-linker-hash-style=gnu --enable-languages=c,c++,objc,obj-c++,java,fortran,ada,go,lto --enable-plugin --enable-initfini-array --disable-libgcj --with-isl=/builddir/build/BUILD/gcc-4.8.5-20150702/obj-x86_64-redhat-linux/isl-install --with-cloog=/builddir/build/BUILD/gcc-4.8.5-20150702/obj-x86_64-redhat-linux/cloog-install --enable-gnu-indirect-function --with-tune=generic --with-arch_32=x86-64 --build=x86_64-redhat-linux
线程模型:posix
gcc 版本 4.8.5 20150623 (Red Hat 4.8.5-44) (GCC)

测试步骤与结果如下:

Go代码测试:

$cd why-go-sucks/billion-loops/go
$go build -o code code.go
$time ./code 10
456953

real    0m3.766s
user    0m3.767s
sys 0m0.007s

C代码测试:

$cd why-go-sucks/billion-loops/c
$gcc -O3 -std=c99 -o code code.c
$time ./code 10
459383

real    0m3.005s
user    0m3.005s
sys 0m0.000s

Java代码测试:

$javac -d . code.java
$time java -cp . jvm.code 10
456181

real    0m3.105s
user    0m3.092s
sys 0m0.027s

从测试结果看到(基于real时间):采用-O3优化的C代码最快,Java落后一个身位,而Go则比C慢了25%,比Java慢了21%

注:time命令的输出结果通常包含三个主要部分:real、user和sys。real是从命令开始执行到结束所经过的实际时间(墙钟时间),我们依次指标为准。user是程序在用户模式下执行所消耗的CPU时间。sys则是程序在内核模式下执行所消耗的CPU时间(系统调用)。如果总时间(real)略低于用户时间(user),这表明程序可能在某些时刻被调度或等待,而不是持续占用CPU。这种情况可能是由于输入输出操作、等待资源等原因。如果real时间显著小于user时间,这种情况通常发生在并发程序中,其中多个线程或进程在不同的时间段执行,导致总的用户CPU时间远大于实际的墙钟时间。sys时间保持较低,说明系统调用的频率较低,程序主要是执行计算而非进行大量的系统交互。

这时作为Gopher的你可能会说:原作者编写的Go测试代码不够优化,我们能优化到比C还快

大家都知道原代码是不够优化的,随意改改计算逻辑就能带来大幅提升。但我们不能忘了“同等条件”这个前提。你采用的优化方法,其他语言(C、Java)也可以采用。

那么,在不改变“同等条件”的前提下,我们还能优化点啥呢?本着能提升一点是一点的思路,我们尝试从下面几个点优化一下,看看效果:

  • 去除不必要的if判断
  • 使用更快的rand实现
  • 关闭边界检查
  • 避免逃逸

下面是修改之后的代码:

// why-go-sucks/billion-loops/go/code_optimize.go 

package main

import (
    "fmt"
    "math/rand"
    "os"
    "strconv"
)

func main() {
    input, _ := strconv.Atoi(os.Args[1]) // Get an input number from the command line
    u := int32(input)
    r := int32(rand.Uint32() % 10000)   // Use Uint32 for faster random number generation
    var a [10000]int32                  // Array of 10k elements initialized to 0
    for i := int32(0); i < 10000; i++ { // 10k outer loop iterations
        for j := int32(0); j < 100000; j++ { // 100k inner loop iterations, per outer loop iteration
            a[i] = a[i] + j%u // Simple sum
        }
        a[i] += r // Add a random value to each element in array
    }
    z := a[r]
    fmt.Println(z) // Print out a single element from the array
}

我们编译并运行一下测试:

$cd why-go-sucks/billion-loops/go
$go build -o code_optimize -gcflags '-B' code_optimize.go
$time ./code_optimize 10
459443

real    0m3.761s
user    0m3.759s
sys 0m0.011s

对比一下最初的测试结果,这些“所谓的优化”没有什么卵用,优化前你估计也能猜测到这个结果,因为除了关闭边界检查,其他优化都没有处于循环执行的热路径之上

注:rand.Uint32() % 10000的确要比rand.Intn(10000)快,我自己的benchmark结果是快约1倍。

那Go程序究竟慢在哪里呢?在“同等条件”下,我能想到的只能是Go编译器后端在代码优化方面优化做的还不够,相较于GCC、Java等老牌编译器还有明显差距。

比如说,原先的代码中在内层循环中频繁访问a[i],导致数组访问的读写操作较多(从内存加载a[i],更新值后写回)。GCC和Java编译器在后端很可能做了这样的优化:将数组元素累积到一个临时变量中,并在外层循环结束后写回数组,这样做可以减少内层循环中的内存读写操作,充分利用CPU缓存和寄存器,加速数据处理

注:数组从内存或缓存读,而一个临时变量很大可能是从寄存器读,那读取速度相差还是很大的。

如果我们手工在Go中实施这一优化,看看能达到什么效果呢?我们改一下最初版本的Go代码(code.go),新代码如下:

// why-go-sucks/billion-loops/go/code_local_var.go 

package main

import (
    "fmt"
    "math/rand"
    "os"
    "strconv"
)

func main() {
    input, e := strconv.Atoi(os.Args[1]) // Get an input number from the command line
    if e != nil {
        panic(e)
    }
    u := int32(input)
    r := int32(rand.Intn(10000))        // Get a random number 0 <= r < 10k
    var a [10000]int32                  // Array of 10k elements initialized to 0
    for i := int32(0); i < 10000; i++ { // 10k outer loop iterations
        temp := a[i]
        for j := int32(0); j < 100000; j++ { // 100k inner loop iterations, per outer loop iteration
            temp += j % u // Simple sum
        }
        temp += r // Add a random value to each element in array
        a[i] = temp
    }
    fmt.Println(a[r]) // Print out a single element from the array
}

编译并运行测试:

$go build -o code_local_var code_local_var.go
$time ./code_local_var 10
459169

real    0m3.017s
user    0m3.017s
sys 0m0.007s

我们看到,测试结果直接就比Java略好一些了。显然Go编译器没有做这种优化,从code.go的汇编也大致可以看出来:


使用lensm生成的汇编与go源码对应关系

而Java显然做了这类优化,我们在原Java代码版本上按上述优化逻辑修改了一下:

// why-go-sucks/billion-loops/java/code_local_var.java

package jvm;

import java.util.Random;

public class code {

    public static void main(String[] args) {
        var u = Integer.parseInt(args[0]); // 获取命令行输入的整数
        var r = new Random().nextInt(10000); // 生成随机数 0 <= r < 10000
        var a = new int[10000]; // 定义长度为10000的数组a

        for (var i = 0; i < 10000; i++) { // 10k外层循环迭代
            var temp = a[i]; // 使用临时变量存储 a[i] 的值
            for (var j = 0; j < 100000; j++) { // 100k内层循环迭代,每次外层循环迭代
                temp += j % u; // 更新临时变量的值
            }
            a[i] = temp + r; // 将临时变量的值加上 r 并写回数组
        }
        System.out.println(a[r]); // 输出 a[r] 的值
    }
}

但从运行这个“优化”后的程序的结果来看,其对java代码的提升幅度几乎可以忽略不计:

$time java -cp . jvm.code 10
450375

real    0m3.043s
user    0m3.028s
sys 0m0.027s

这也直接证明了即便采用的是原版java代码,java编译器也会生成带有抽取局部变量这种优化的可执行代码,java程序员无需手工进行此类优化。

像这种编译器优化,还有不少,比如大家比较熟悉的循环展开(Loop Unrolling)也可以提升Go程序的性能:

// why-go-sucks/billion-loops/go/code_loop_unrolling.go

package main

import (
    "fmt"
    "math/rand"
    "os"
    "strconv"
)

func main() {
    input, e := strconv.Atoi(os.Args[1]) // Get an input number from the command line
    if e != nil {
        panic(e)
    }
    u := int32(input)
    r := int32(rand.Intn(10000))        // Get a random number 0 <= r < 10k
    var a [10000]int32                  // Array of 10k elements initialized to 0
    for i := int32(0); i < 10000; i++ { // 10k outer loop iterations
        var sum int32
        // Unroll inner loop in chunks of 4 for optimization
        for j := int32(0); j < 100000; j += 4 {
            sum += j % u
            sum += (j + 1) % u
            sum += (j + 2) % u
            sum += (j + 3) % u
        }
        a[i] = sum + r // Add the accumulated sum and random value
    }

    fmt.Println(a[r]) // Print out a single element from the array
}

运行这个Go测试程序,性能如下:

$go build -o code_loop_unrolling code_loop_unrolling.go
$time ./code_loop_unrolling 10
458908

real    0m2.937s
user    0m2.940s
sys 0m0.002s

循环展开可以增加指令级并行性,因为展开后的代码块中可以有更多的独立指令,比如示例中的计算j % u、(j+1) % u、(j+2) % u和(j+3) % u,这些计算操作是独立的,可以并行执行,打破了依赖链,从而更好地利用处理器的并行流水线。而原版Go代码中,每次迭代都会根据前一次迭代的结果更新a[i],形成一个依赖链,这种顺序依赖性迫使处理器只能按顺序执行这些指令,导致流水线停顿。

不过其他语言也可以做同样的手工优化,比如我们对C代码做同样的优化(why-go-sucks/billion-loops/c/code_loop_unrolling.c),c测试程序的性能可以提升至2.7s水平,这也证明了初版C程序即便在-O3的情况下编译器也没有自动为其做这个优化:

$time ./code_loop_unrolling 10
459383

real    0m2.723s
user    0m2.722s
sys 0m0.001s

到这里我们就不再针对这个10亿次循环的性能问题做进一步展开了,从上面的探索得到的初步结论就是Go编译器优化做的还不到位所致,期待后续Go团队能在编译器优化方面投入更多精力,争取早日追上GCC/Clang、Java这些成熟的编译器优化水平。

下面我们再来看Go在百万任务场景下内存开销大的“问题”。

2. 内存占用高,问题出在Goroutine实现原理

我们先来看第二个问题的测试代码:

package main

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

func main() {
    numRoutines := 100000
    if len(os.Args) > 1 {
        n, err := strconv.Atoi(os.Args[1])
        if err == nil {
            numRoutines = n
        }
    }

    var wg sync.WaitGroup
    for i := 0; i < numRoutines; i++ {
        wg.Add(1)
        go func() {
            time.Sleep(10 * time.Second)
            wg.Done()
        }()
    }
    wg.Wait()
}

这个代码其实就是根据传入的task数量启动等同数量的goroutine,然后每个goroutine模拟工作负载sleep 10s,这等效于百万长连接的场景,只有连接,但没有收发消息。

相对于上一个问题,这个问题更好解释一些。

Go使用的groutine是一种有栈协程,文章中使用的是每个task一个goroutine的模型,且维护百万任务一段时间,这会真实创建百万个goroutine(G数据结构),并为其分配栈空间(2k起步),这样你可以算一算,不考虑其他结构的占用,仅每个goroutine的栈空间所需的内存都是极其可观的:

mem = 1000000 * 2000 Bytes = 2000000000 Bytes = 2G Bytes

所以启动100w goroutine,保底就2GB内存出去了,这与原作者测试的结果十分契合(原文是2.5GB多)。并且,内存还会随着goroutine数量增长而线性增加。

那么如何能减少内存使用呢?如果采用每个task一个goroutine的模型,这个内存占用很难省去,除非将来Go团队对goroutine实现做大修。

如果task是网络通信相关的,可以使用类似gnet这样的直接基于epoll建构的框架,其主要的节省在于不会启动那么多goroutine,而是通过一个goroutine池来处理数据,每个池中的goroutine负责一批网络连接或网络请求。

在一些Gopher的印象中,Goroutine一旦分配就不回收,这会使他们会误认为一旦分配了100w goroutine,这2.5G内存空间将始终被占用,真实情况是这样么?我们用一个示例程序验证一下就好了:

// why-go-sucks/million-tasks/million-tasks.go

package main

import (
    "fmt"
    "log"
    "os"
    "os/signal"
    "runtime"
    "sync"
    "syscall"
    "time"
)

// 打印当前内存使用情况和相关信息
func printMemoryUsage() {
    var m runtime.MemStats
    runtime.ReadMemStats(&m)

    // 获取当前 goroutine 数量
    numGoroutines := runtime.NumGoroutine()

    // 获取当前线程数量
    numThreads := runtime.NumCPU() // Go runtime 不直接提供线程数量,但可以通过 NumCPU 获取逻辑处理器数量

    fmt.Printf("======>\n")
    fmt.Printf("Alloc = %v MiB", bToMb(m.Alloc))
    fmt.Printf("\tTotalAlloc = %v MiB", bToMb(m.TotalAlloc))
    fmt.Printf("\tSys = %v MiB", bToMb(m.Sys))
    fmt.Printf("\tNumGC = %v", m.NumGC)
    fmt.Printf("\tNumGoroutines = %v", numGoroutines)
    fmt.Printf("\tNumThreads = %v\n", numThreads)
    fmt.Printf("<======\n\n")
}

// 将字节转换为 MB
func bToMb(b uint64) uint64 {
    return b / 1024 / 1024
}

func main() {
    const signal1Goroutines = 900000
    const signal2Goroutines = 90000
    const signal3Goroutines = 10000

    // 用于接收退出信号
    sigChan := make(chan os.Signal, 1)
    signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)

    // 控制 goroutine 的退出
    signal1Chan := make(chan struct{})
    signal2Chan := make(chan struct{})
    signal3Chan := make(chan struct{})

    var wg sync.WaitGroup
    ticker := time.NewTicker(5 * time.Second)
    go func() {
        for range ticker.C {
            printMemoryUsage()
        }
    }()

    // 等待退出信号
    go func() {
        count := 0
        for {
            <-sigChan
            count++
            if count == 1 {
                log.Println("收到第一类goroutine退出信号")
                close(signal1Chan) // 关闭 signal1Chan,通知第一类 goroutine 退出
                continue
            }
            if count == 2 {
                log.Println("收到第二类goroutine退出信号")
                close(signal2Chan) // 关闭 signal2Chan,通知第二类 goroutine 退出
                continue
            }
            log.Println("收到第三类goroutine退出信号")
            close(signal3Chan) // 关闭 signal3Chan,通知第三类 goroutine 退出
            return
        }
    }()

    // 启动第一类 goroutine(在收到 signal1 时退出)
    log.Println("开始启动第一类goroutine...")
    for i := 0; i < signal1Goroutines; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            // 模拟工作
            for {
                select {
                case <-signal1Chan:
                    return
                default:
                    time.Sleep(10 * time.Second) // 模拟一些工作
                }
            }
        }(i)
    }
    log.Println("启动第一类goroutine(900000) ok")

    time.Sleep(time.Second * 5)

    // 启动第二类 goroutine(在收到 signal2 时退出)
    log.Println("开始启动第二类goroutine...")
    for i := 0; i < signal2Goroutines; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            // 模拟工作
            for {
                select {
                case <-signal2Chan:
                    return
                default:
                    time.Sleep(10 * time.Second) // 模拟一些工作
                }
            }
        }(i)
    }
    log.Println("启动第二类goroutine(90000) ok")

    time.Sleep(time.Second * 5)

    // 启动第三类goroutine(随程序退出而退出)
    log.Println("开始启动第三类goroutine...")
    for i := 0; i < signal3Goroutines; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            // 模拟工作
            for {
                select {
                case <-signal3Chan:
                    return
                default:
                    time.Sleep(10 * time.Second) // 模拟一些工作
                }
            }
        }(i)
    }
    log.Println("启动第三类goroutine(90000) ok")

    // 等待所有 goroutine 完成
    wg.Wait()
    fmt.Println("所有 goroutine 已退出,程序结束")
}

这个程序我就不详细解释了。大致分三类goroutine,第一类90w个,在我发送第一个ctrl+c信号后退出,第二类9w个,在我发送第二个ctrl+c信号后退出,最后一类1w个,随着程序退出而退出。

在我的执行环境下编译和执行一下这个程序,并结合runtime输出以及使用top -p pid的方式查看其内存占用:

$go build million-tasks.go
$./million-tasks 

2024/12/01 22:07:03 开始启动第一类goroutine...
2024/12/01 22:07:05 启动第一类goroutine(900000) ok
======>
Alloc = 511 MiB TotalAlloc = 602 MiB    Sys = 2311 MiB  NumGC = 9   NumGoroutines = 900004  NumThreads = 8
<======

2024/12/01 22:07:10 开始启动第二类goroutine...
2024/12/01 22:07:11 启动第二类goroutine(90000) ok
======>
Alloc = 577 MiB TotalAlloc = 668 MiB    Sys = 2553 MiB  NumGC = 9   NumGoroutines = 990004  NumThreads = 8
<======

2024/12/01 22:07:16 开始启动第三类goroutine...
2024/12/01 22:07:16 启动第三类goroutine(90000) ok
======>
Alloc = 597 MiB TotalAlloc = 688 MiB    Sys = 2593 MiB  NumGC = 9   NumGoroutines = 1000004 NumThreads = 8
<======

======>
Alloc = 600 MiB TotalAlloc = 690 MiB    Sys = 2597 MiB  NumGC = 9   NumGoroutines = 1000004 NumThreads = 8
<======
... ...

======>
Alloc = 536 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 10  NumGoroutines = 1000004 NumThreads = 8
<======

100w goroutine全部创建ok后,我们查看一下top输出:

  PID USER      PR  NI    VIRT    RES    SHR S  %CPU %MEM     TIME+ COMMAND
 5800 root      20   0 3875556   2.5g    988 S  54.0  8.2   0:30.92 million-tasks

我们看到RES为2.5g,和我们预期的一致!

接下来,我们停掉第一批90w个goroutine,看RES是否会下降,何时会下降!

输入ctrl+c,停掉第一批90w goroutine:

^C2024/12/01 22:10:15 收到第一类goroutine退出信号
======>
Alloc = 536 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 10  NumGoroutines = 723198  NumThreads = 8
<======

======>
Alloc = 536 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 10  NumGoroutines = 723198  NumThreads = 8
<======

======>
Alloc = 536 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 10  NumGoroutines = 100004  NumThreads = 8
<======

======>
Alloc = 536 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 10  NumGoroutines = 100004  NumThreads = 8
<======
... ...

但同时刻的top显示RES并没有变化:

  PID USER      PR  NI    VIRT    RES    SHR S  %CPU %MEM     TIME+ COMMAND
 5800 root      20   0 3875812   2.5g    988 S   0.0  8.2   0:56.38 million-tasks

等待两个GC间隔的时间后(大约4分),Goroutine的栈空间被释放:

======>
Alloc = 465 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 12  NumGoroutines = 100004  NumThreads = 8
<======

======>
Alloc = 465 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 12  NumGoroutines = 100004  NumThreads = 8
<======

======>
Alloc = 465 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 12  NumGoroutines = 100004  NumThreads = 8
<======

======>
Alloc = 465 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 12  NumGoroutines = 100004  NumThreads = 8
<======

top显示RES从2.5g下降为大概700多MB(RES的单位是KB):

  PID USER      PR  NI    VIRT    RES    SHR S  %CPU %MEM     TIME+ COMMAND
 5800 root      20   0 3875812 764136    992 S   0.0  2.4   1:01.87 million-tasks

接下来,我们再停掉第二批9w goroutine:

^C2024/12/01 22:16:21 收到第二类goroutine退出信号
======>
Alloc = 465 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 13  NumGoroutines = 100004  NumThreads = 8
<======

======>
Alloc = 465 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 13  NumGoroutines = 100004  NumThreads = 8
<======

======>
Alloc = 465 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 13  NumGoroutines = 10004   NumThreads = 8
<======

======>
Alloc = 465 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 13  NumGoroutines = 10004   NumThreads = 8
<======

此时,top值也没立即改变:

  PID USER      PR  NI    VIRT    RES    SHR S  %CPU %MEM     TIME+ COMMAND
 5800 root      20   0 3875812 764136    992 S   0.0  2.4   1:05.99 million-tasks

大约等待一个GC间隔(2分钟)后,top中RES下降:

======>
Alloc = 458 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 14  NumGoroutines = 10004   NumThreads = 8
<======

======>
Alloc = 458 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 14  NumGoroutines = 10004   NumThreads = 8
<======

======>
Alloc = 458 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 14  NumGoroutines = 10004   NumThreads = 8
<======

RES变为不到700M:

  PID USER      PR  NI    VIRT    RES    SHR S  %CPU %MEM     TIME+ COMMAND
 5800 root      20   0 3875812 699156    992 S   0.0  2.2   1:06.75 million-tasks

第三次按下ctrl+c,程序退出:

^C2024/12/01 22:18:46 收到第三类goroutine退出信号
======>
Alloc = 458 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 14  NumGoroutines = 10003   NumThreads = 8
<======

======>
Alloc = 458 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 14  NumGoroutines = 10003   NumThreads = 8
<======

所有 goroutine 已退出,程序结束

我们看到Go是会回收goroutine占用的内存空间的,并且归还给OS,只是这种归还比较lazy。尤其是,第二次停止goroutine前,go程序剩下10w goroutine,按理论来讲需占用大约200MB的空间,实际上却是700多MB;第二次停止goroutine后,goroutine数量降为1w,理论占用应该在20MB,但实际却是600多MB,我们看到go运行时这种lazy归还OS内存的行为可能也是“故意为之”,是为了避免反复从OS申请和归还内存。

3. 小结

本文主要探讨了Go语言在十亿次循环和百万任务的测试中的表现令人意外地逊色于Java和C语言的原因。我认为Go在循环执行中的慢速表现,主要是其编译器优化不足,影响了执行效率。 而在内存开销方面,Go的Goroutine实现是使得内存使用量大幅增加的“罪魁祸首”,这是由于每个Goroutine初始都会分配固定大小的栈空间。

通过本文的探讨,我的主要目的是希望大家不要以讹传讹,而是要搞清楚背后的真正原因,并正视Go在某些方面的不足,以及其当前在某些应用上下文中的局限性。 同时,也希望Go开发团队在编译器优化方面进行更多投入,以提升Go在高性能计算领域的竞争力。

本文涉及的源码可以在这里下载。

4. 参考资料


Gopher部落知识星球在2024年将继续致力于打造一个高品质的Go语言学习和交流平台。我们将继续提供优质的Go技术文章首发和阅读体验。同时,我们也会加强代码质量和最佳实践的分享,包括如何编写简洁、可读、可测试的Go代码。此外,我们还会加强星友之间的交流和互动。欢迎大家踊跃提问,分享心得,讨论技术。我会在第一时间进行解答和交流。我衷心希望Gopher部落可以成为大家学习、进步、交流的港湾。让我相聚在Gopher部落,享受coding的快乐! 欢迎大家踊跃加入!

img{512x368}
img{512x368}

img{512x368}
img{512x368}

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻) – https://gopherdaily.tonybai.com

我的联系方式:

  • 微博(暂不可用):https://weibo.com/bigwhite20xx
  • 微博2:https://weibo.com/u/6484441286
  • 博客:tonybai.com
  • github: https://github.com/bigwhite
  • Gopher Daily归档 – https://github.com/bigwhite/gopherdaily
  • Gopher Daily Feed订阅 – https://gopherdaily.tonybai.com/feed

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 Go语言编程指南
商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats