2024-12-26 05:41:41
本文永久链接 – https://tonybai.com/2024/12/26/exploring-the-connection-establish-process-of-webrtc-app-built-with-pion
在《WebRTC第一课:从信令、ICE到NAT穿透的连接建立全流程》一文中,我们从理论层面全面细致地了解了WebRTC连接建立的完整流程。这个流程大致可以分为以下几个阶段:
这个过程的复杂性不言而喻。即便多次阅读全文,读者可能仍难以形成深入的理解。因此,如果能够配上一个真实的示例,相信会更有助于读者全面把握这一过程的细节和原理。
在这篇文章中,我就为大家呈现一个真实的示例,我将使用Go语言开源WebRTC项目pion/webrtc来实现一个基于datachannel的WebRTC演示版程序,通过将pion/webrtc的日志级别设置为TRACE级,输出更多pion/webrtc实现层面的日志,以帮助大家理解WebRTC建连过程。同时,我还会实现一个简易版的基于“Room抽象模型”的信令服务器,供WebRTC通信两端交换信息使用。希望该示例能帮助大家更好的理解WebRTC端到端的建连流程。
按照WebRTC建连的流程,我们先来实现一个简易版的信令服务器。
注:提醒各位读者,本文中所有例子均以演示和帮助大家理解为目的,不建议在生产中使用示例中的代码。
下面是一个基于WebSocket的WebRTC信令服务器的简化实现,使用WebSocket进行WebRTC信令交换可以提供更快速、更高效和更灵活的通信体验,同时WebSocket生态丰富,可复用的代码库有很多,实现起来也比较简单。
这个信令服务器是基于Room抽象模型的,因此其主要结构是一个Room结构体,代表一个聊天室。我们具体看一下该信令服务器的实现代码:
// webrtc-first-lesson/part2/signaling-server/main.go
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"sync"
"github.com/gorilla/websocket"
)
type Room struct {
Clients map[*websocket.Conn]bool
mu sync.Mutex
}
var (
upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
}
rooms = make(map[string]*Room)
roomMu sync.Mutex
)
func main() {
http.HandleFunc("/ws", handleWebSocket)
log.Println("Signaling server starting on :28080")
log.Fatal(http.ListenAndServe(":28080", nil))
}
func handleWebSocket(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Println("Error upgrading to WebSocket:", err)
return
}
defer conn.Close()
remoteAddr := conn.RemoteAddr().String()
log.Println("New WebSocket connection from:", remoteAddr)
roomID := r.URL.Query().Get("room")
if roomID == "" {
roomID = fmt.Sprintf("room_%d", len(rooms)+1)
log.Printf("Created new room: %s\n", roomID)
}
roomMu.Lock()
room, exists := rooms[roomID]
if !exists {
room = &Room{Clients: make(map[*websocket.Conn]bool)}
rooms[roomID] = room
}
roomMu.Unlock()
room.mu.Lock()
room.Clients[conn] = true
room.mu.Unlock()
log.Printf("Client[%v] joined room %s\n", remoteAddr, roomID)
for {
messageType, message, err := conn.ReadMessage()
if err != nil {
log.Println("Error reading message:", err)
break
}
var msg map[string]interface{}
if err := json.Unmarshal(message, &msg); err != nil {
log.Println("Error unmarshaling message:", err)
continue
}
msg["roomId"] = roomID
updatedMessage, _ := json.Marshal(msg)
room.mu.Lock()
for client := range room.Clients {
if client != conn {
clientAddr := client.RemoteAddr().String()
if err := client.WriteMessage(messageType, updatedMessage); err != nil {
log.Println("Error writing message:", err)
} else {
log.Printf("writing message to client[%v] ok\n", clientAddr)
}
}
}
room.mu.Unlock()
}
room.mu.Lock()
delete(room.Clients, conn)
room.mu.Unlock()
log.Printf("Client[%v] left room %s\n", remoteAddr, roomID)
}
我们看到:Room结构体包含一个WebSocket连接的map和一个互斥锁。演示程序使用全局变量rooms(房间map)和相应的互斥锁管理房间和加入房间的连接,并在房间内进行消息广播,以保证消息能转发到参与通信的所有端(Peer)。当然,如果仅有两端在一个房间中,那么这就变成了一对一的实时通信。
这个信令服务器程序启动后,默认监听28080端口,当客户端连接时,会根据URL参数来将客户端连接加入到某个房间,如果房间号参数为空,则代表该客户端期望创建一个房间。先创建房间并加入的客户端作为answer端,等待offer端的连接。当从某个客户端连接收到消息后,会广播给房间内的其他客户端。当客户端断开连接时,便将其从房间中移除。
当然这仅是一个演示版程序,并未对历史建立的房间进行回收,同时也没有进行身份认证等安全方面的控制。
接下来,我们再来看看借助信令服务器进行端到端实时通信的端侧WebRTC应用的实现。
WebRTC应用的代码通常都很“样板化”。在开发WebRTC应用程序时,信令连接、设置本地和远程描述、收集ICE候选以及转发信令消息等步骤都是一些常见且重复性较高的任务。这些步骤在不同的WebRTC应用程序中通常都大同小异。以下是这些重复性任务的一些具体步骤示例:
1) 信令连接处理
– 创建信令通道(如WebSocket连接)
– 监听连接建立、断开等事件
– 通过信令通道交换offer/answer等信令消息
2) 本地和远程描述设置
– 创建c实例
– 设置本地描述(createOffer/createAnswer)
– 设置远程描述(setRemoteDescription)
3) ICE 候选收集与交换
– 监听ICE候选事件,收集本地ICE候选
– 通过信令通道交换ICE候选信息
– 将远程ICE候选添加到RTCPeerConnection实例
4) 信令消息转发
– 接收来自远程的信令消息
– 根据消息类型,转发给本地RTCPeerConnection实例
这些基本步骤在大多数WebRTC应用程序中都是必需的。我们的示例代码也不例外,下面就是webrtc-peer程序源码,有些长,也很繁琐:
// webrtc-first-lesson/part2/webrtc-peer/main.go
package main
import (
"encoding/json"
"flag"
"fmt"
"log"
"time"
"github.com/gorilla/websocket"
"github.com/pion/logging"
"github.com/pion/webrtc/v3"
)
type signalMsg struct {
Type string `json:"type"`
Data string `json:"data"`
}
var (
signalingServer string
roomID string
)
func init() {
flag.StringVar(&signalingServer, "server", "ws://localhost:28080/ws", "Signaling server WebSocket URL")
flag.StringVar(&roomID, "room", "", "Room ID (leave empty to create a new room)")
flag.Parse()
}
func main() {
// Connect to signaling server
signalingURL := fmt.Sprintf("%s?room=%s", signalingServer, roomID)
conn, _, err := websocket.DefaultDialer.Dial(signalingURL, nil)
if err != nil {
log.Fatal("Error connecting to signaling server:", err)
}
defer conn.Close()
log.Println("connect to signaling server ok")
// Create a new RTCPeerConnection
config := webrtc.Configuration{
ICEServers: []webrtc.ICEServer{
{
URLs: []string{"stun:stun.l.google.com:19302"},
},
},
}
// 创建一个自定义的日志工厂
loggerFactory := logging.NewDefaultLoggerFactory()
loggerFactory.DefaultLogLevel = logging.LogLevelTrace
//loggerFactory.DefaultLogLevel = logging.LogLevelInfo
//loggerFactory.DefaultLogLevel = logging.LogLevelDebug
// Enable detailed logging
s := webrtc.SettingEngine{}
s.LoggerFactory = loggerFactory
s.SetICETimeouts(5*time.Second, 5*time.Second, 5*time.Second)
api := webrtc.NewAPI(webrtc.WithSettingEngine(s))
peerConnection, err := api.NewPeerConnection(config)
if err != nil {
log.Fatal(err)
}
// Create a datachannel
dataChannel, err := peerConnection.CreateDataChannel("test", nil)
if err != nil {
log.Fatal(err)
}
dataChannel.OnOpen(func() {
log.Println("Data channel is open")
go func() {
for {
err := dataChannel.SendText("Hello from " + roomID)
if err != nil {
log.Println(err)
}
time.Sleep(5 * time.Second)
}
}()
})
dataChannel.OnMessage(func(msg webrtc.DataChannelMessage) {
log.Printf("Received message: %s\n", string(msg.Data))
})
// Set the handler for ICE connection state
peerConnection.OnICEConnectionStateChange(func(connectionState webrtc.ICEConnectionState) {
log.Printf("ICE Connection State has changed: %s\n", connectionState.String())
})
// Set the handler for Peer connection state
peerConnection.OnConnectionStateChange(func(s webrtc.PeerConnectionState) {
log.Printf("Peer Connection State has changed: %s\n", s.String())
})
// Set the handler for Signaling state
peerConnection.OnSignalingStateChange(func(s webrtc.SignalingState) {
log.Printf("Signaling State has changed: %s\n", s.String())
})
// Register data channel creation handling
peerConnection.OnDataChannel(func(d *webrtc.DataChannel) {
log.Printf("New DataChannel %s %d\n", d.Label(), d.ID())
d.OnOpen(func() {
log.Printf("Data channel '%s'-'%d' open.\n", d.Label(), d.ID())
})
d.OnMessage(func(msg webrtc.DataChannelMessage) {
log.Printf("Message from DataChannel '%s': '%s'\n", d.Label(), string(msg.Data))
})
})
// Set the handler for ICE candidate generation
peerConnection.OnICECandidate(func(i *webrtc.ICECandidate) {
if i == nil {
return
}
candidateString, err := json.Marshal(i.ToJSON())
if err != nil {
log.Println(err)
return
}
if writeErr := conn.WriteJSON(&signalMsg{
Type: "candidate",
Data: string(candidateString),
}); writeErr != nil {
log.Println(writeErr)
}
})
// Handle incoming messages from signaling server
go func() {
for {
_, rawMsg, err := conn.ReadMessage()
if err != nil {
log.Println("Error reading message:", err)
return
}
log.Println("recv msg from signaling server")
var msg signalMsg
if err := json.Unmarshal(rawMsg, &msg); err != nil {
log.Println("Error parsing message:", err)
continue
}
log.Println("recv msg is", msg)
switch msg.Type {
case "offer":
log.Println("recv a offer msg")
offer := webrtc.SessionDescription{}
if err := json.Unmarshal([]byte(msg.Data), &offer); err != nil {
log.Println("Error parsing offer:", err)
continue
}
if err := peerConnection.SetRemoteDescription(offer); err != nil {
log.Println("Error setting remote description:", err)
continue
}
answer, err := peerConnection.CreateAnswer(nil)
if err != nil {
log.Println("Error creating answer:", err)
continue
}
if err := peerConnection.SetLocalDescription(answer); err != nil {
log.Println("Error setting local description:", err)
continue
}
answerString, err := json.Marshal(answer)
if err != nil {
log.Println("Error encoding answer:", err)
continue
}
if err := conn.WriteJSON(&signalMsg{
Type: "answer",
Data: string(answerString),
}); err != nil {
log.Println("Error sending answer:", err)
}
log.Println("send answer ok")
case "answer":
log.Println("recv a answer msg")
answer := webrtc.SessionDescription{}
if err := json.Unmarshal([]byte(msg.Data), &answer); err != nil {
log.Println("Error parsing answer:", err)
continue
}
if err := peerConnection.SetRemoteDescription(answer); err != nil {
log.Println("Error setting remote description:", err)
}
log.Println("set remote desc for answer ok")
case "candidate":
candidate := webrtc.ICECandidateInit{}
if err := json.Unmarshal([]byte(msg.Data), &candidate); err != nil {
log.Println("Error parsing candidate:", err)
continue
}
if err := peerConnection.AddICECandidate(candidate); err != nil {
log.Println("Error adding ICE candidate:", err)
}
log.Println("adding ICE candidate:", candidate)
}
}
}()
// Create an offer if we are the peer to join the room
if roomID != "" {
offer, err := peerConnection.CreateOffer(nil)
if err != nil {
log.Fatal(err)
}
if err := peerConnection.SetLocalDescription(offer); err != nil {
log.Fatal(err)
}
offerString, err := json.Marshal(offer)
if err != nil {
log.Fatal(err)
}
if err := conn.WriteJSON(&signalMsg{
Type: "offer",
Data: string(offerString),
}); err != nil {
log.Fatal(err)
}
log.Printf("send offer to signaling server ok\n")
}
// Wait forever
select {}
}
通过代码,我们看到:这个使用Go实现的WebRTC对等连接示例程序通过WebSocket与信令服务器通信,创建和管理RTCPeerConnection,处理ICE候选、offer和answer,并实现了数据通道功能。程序支持创建新房间或加入现有房间,展示了完整的WebRTC连接建立流程,包括信令交换和ICE处理。它通过对pion/webrtc的日志级别设置让其具有详细的日志记录能力,这为我们后续通过日志分别WebRTC建连各个阶段奠定了基础。
下面是实验环境的拓扑图:
webrtc-peer分别位于两台服务器上,其中Host A是一台位于NAT后面的内网主机,而HOST B则是一台位于美国的公网主机,信令服务器搭建在HOST B上,stun服务器使用的是Google提供的公网免费stun server。
下面是信令服务器和两端peer服务器的编译和启动步骤:
我们先启动信令服务器:
//在Host B上signaling-server目录下
$make
$./signaling-server
2024/08/20 21:45:50 Signaling server starting on :28080
接下来,启动Host A上的webrtc-peer程序:
//在Host A上webrtc-peer目录下
$make
$./webrtc-peer -server ws://206.189.166.16:28080/ws
这时信令服务器就会发现有新的websocket连入,并创建了room_6(这只是多次运行中的某一次的room id罢了):
2024/08/20 21:48:52 New WebSocket connection from: 47.93.3.95:17355
2024/08/20 21:48:52 Created new room: room_6
2024/08/20 21:48:52 Client[47.93.3.95:17355] joined room room_6
然后我们启动Host B上的webrtc-peer程序,将这一端加入到上面创建的room_6中:
//在Host B上webrtc-peer目录下
$make
$./webrtc-peer -room room_6 -server ws://206.189.166.16:28080/ws
这之后,信令服务器也会发现Host B上的webrtc-peer的连接。之后便开始从信令交互开始逐步实现端到端的建连。以下是对各个阶段产生的详细日志的分析:
"2024/08/20 21:45:48 connect to signaling server ok"
以上日志表示成功连接到信令服务器。如果房间号为空,则该peer(answer)先启动并在信令服务器建立房间,然后另一个peer(offer)加入该房间,通过信令服务器交换信息。
下面日志则是表示接收到另一个peer的offer SDP:
"2024/08/20 21:45:55 recv msg is {offer {"type":"offer","sdp":"v=0\r\no=- 2149168073199454578 1724143555 IN IP4 0.0.0.0\r\ns=-\r\nt=0 0\r\na=msid-semantic:WMS*\r\na=fingerprint:sha-256 A6:D6:AE:F3:30:0D:D8:07:D2:23:C9:A5:69:27:F2:CC:B1:8C:A4:DB:30:79:E7:62:9B:09:87:B7:68:1F:55:A7\r\na=extmap-allow-mixed\r\na=group:BUNDLE 0\r\nm=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\nc=IN IP4 0.0.0.0\r\na=setup:actpass\r\na=mid:0\r\na=sendrecv\r\na=sctp-port:5000\r\na=ice-ufrag:TYfjBFmqpgGEtKbh\r\na=ice-pwd:NGdAyXsOgVwFfzXnlLmNrcWrBgJWFceB\r\n"}}
其中”recv a offer msg”表示程序识别到收到了offer消息。而”offer := webrtc.SessionDescription{}”及后续代码则是处理offer,创建answer并发送回给另一个peer。
在WebRTC中,信令服务器用于交换SDP(Session Description Protocol)信息,SDP描述了连接的媒体信息,如编解码器、IP 地址、端口等。先启动的peer创建房间,等待offer,后加入的peer发送offer后,等待answer的回复,双方通过信令服务器交换这些信息以建立连接。
接下来,便是两端的ICE流程。
下面一行日志表示开始收集ICE 候选者,这里是一个host类型的候选者:
"2024/08/20 21:45:55 adding ICE candidate: {candidate:3384150427 1 udp 2130706431 206.189.166.16 52256 typ host 0xc000210230 0xc0002121fe <nil>}"
后续有多个类似的日志,分别添加不同类型的候选者,如 host、srflx(Server Reflexive)等:
2024/08/20 21:45:55 adding ICE candidate: {candidate:604015337 1 udp 2130706431 10.46.0.5 38367 typ host 0xc000210260 0xc000212250 <nil>}
2024/08/20 21:45:55 adding ICE candidate: {candidate:3019421960 1 udp 2130706431 2604:a880:2:d0::2094:3001 48394 typ host 0xc000210290 0xc000212298 <nil>}
2024/08/20 21:45:55 adding ICE candidate: {candidate:2090009598 1 udp 2130706431 10.0.0.1 58895 typ host 0xc0002102d0 0xc0002122e0 <nil>}
2024/08/20 21:45:55 adding ICE candidate: {candidate:233762139 1 udp 2130706431 172.17.0.1 58343 typ host 0xc000210300 0xc000212328 <nil>}
2024/08/20 21:45:55 adding ICE candidate: {candidate:2943811937 1 udp 1694498815 2604:a880:2:d0::2094:3001 40480 typ srflx raddr :: rport 40480 0xc00038c070 0xc00038e050 <nil>}
2024/08/20 21:45:55 adding ICE candidate: {candidate:2614874796 1 udp 1694498815 206.189.166.16 38534 typ srflx raddr 0.0.0.0 rport 38534 0xc000210760 0xc000212b98 <nil>}
不过,在输出的日志中,我们看到并没有明确输出我们期待的经过 Candidate Priorization(候选者优先级排序)后的候选者排序列表。
注:重温一下ICE(Interactive Connectivity Establishment),这是一种用于在两个peer之间建立连接的协议,通过收集各种类型的候选者(如 host 表示本机地址、srflx 表示通过 NAT 反射得到的地址等),增加连接成功的可能性。
在ICE连接中,会确定一个controlling方和一个controlled方,用于决定连接的发起和响应顺序。 下面这行输出日志表示本端不是controlling方:
"ice DEBUG: 21:45:55.401065 agent.go:395: Started agent: isControlling? false, remoteUfrag: "TYfjBFmqpgGEtKbh", remotePwd: "NGdAyXsOgVwFfzXnlLmNrcWrBgJWFceB"
这个阶段日志中没有明确输出检查列表,但日志中有大量的“Ping STUN from… to…”表示正在进行连接检查,这些日志汇总在一起可以看成是形成的检查列表。例如:
ice TRACE: 21:45:55.401676 agent.go:999: Ping STUN from udp4 host 172.17.0.1:7115 to udp4 host 206.189.166.16:52256。
每一端都会通过发送STUN请求来检查不同候选者之间的连接性。
日志中有很多类似的日志表示收到了来自特定候选者的成功响应:
"ice TRACE: 21:45:55.563530 selection.go:229: Inbound STUN (SuccessResponse) from udp4 host 206.189.166.16:52256 to udp4 host 172.17.0.1:7115"
根据连接检查的结果,如果发现Peer Reflexive 候选,也会有相应的日志输出,比如:
ice DEBUG: 21:45:25.771665 agent.go:1147: Adding a new peer-reflexive candidate: 192.168.0.124:61194
ice DEBUG: 21:45:25.772355 agent.go:1147: Adding a new peer-reflexive candidate: 192.168.0.124:26408
ice DEBUG: 21:45:25.775320 agent.go:1147: Adding a new peer-reflexive candidate: 192.168.0.124:40491
ice DEBUG: 21:45:25.776894 agent.go:1147: Adding a new peer-reflexive candidate: 192.168.0.124:5767
ice DEBUG: 21:45:25.777018 agent.go:1147: Adding a new peer-reflexive candidate: 192.168.0.124:61432
... ...
日志中大量的”Ping STUN”和”Inbound STUN (SuccessResponse)”表示正在进行 NAT 穿透尝试。例如:
ice TRACE: 21:45:55.401676 agent.go:999: Ping STUN from udp4 host 172.17.0.1:7115 to udp4 host 206.189.166.16:52256
ice TRACE: 21:45:55.563530 selection.go:229: Inbound STUN (SuccessResponse) from udp4 host 206.189.166.16:52256 to udp4 host 172.17.0.1:7115
通过STUN请求和响应来确定是否能够穿透NAT,如果穿透失败,则将其标记为failed:
ice TRACE: 21:45:56.274839 agent.go:550: Maximum requests reached for pair prio 9151314440652587007 (local, prio 2130706431) udp4 host 172.18.0.1:59520 <-> udp4 host 10.0.0.1:58895 (remote, prio 2130706431), state: in-progress, nominated: false, nominateOnBindingSuccess: false, marking it as failed
如果能够成功穿透,则可以建立连接。下面的日志表示选出了最终的最佳候选者对:
ice TRACE: 21:45:56.656900 agent.go:524: Set selected candidate pair: prio 9151314440652587007 (local, prio 2130706431) udp4 host 192.168.10.1:60662 <-> udp4 host 206.189.166.16:52256 (remote, prio 2130706431), state: succeeded, nominated: true, nominateOnBindingSuccess: false
ice TRACE: 21:45:56.823017 selection.go:239: Found valid candidate pair: prio 9151314440652587007 (local, prio 2130706431) udp4 host 192.168.10.1:60662 <-> udp4 host 206.189.166.16:52256 (remote, prio 2130706431), state: succeeded, nominated: true, nominateOnBindingSuccess: false
一旦确定了最佳候选者对,连接就算建立成功了!
接下来,就是打开datachannel通道并进行数据传输了!
下面日志表示数据通道已打开:
"Data channel is open"
下面日志表示创建了一个名为“test”的数据通道:
"New DataChannel test 824638605290"
下面日志表示数据通道打开成功:
"Data channel 'test'-'824638605290' open"
示例代码中,启动一个goroutine用于定时向data channel发送数据,当出现下面日志时,表示接收到来自另一个 peer 的数据:
"Message from DataChannel 'test': 'Hello from room_6'"
在这篇文章中,我通过使用Go语言开源项目pion/webrtc实现的webrtc端侧应用,为大家详细展示了WebRTC应用的建连过程。
首先,我实现了一个基于WebSocket的简易信令服务器。这个信令服务器基于Room抽象模型,使用全局变量来管理房间和连接,并进行消息广播。
接下来,我介绍了端侧WebRTC应用的实现。这个应用通过与信令服务器通信,创建RTCPeerConnection,处理ICE候选、offer和answer,以及实现数据通道功能。我还通过设置TRACE日志级别,展示了详细的建连流程。
之后,我在实验环境的实际执行了上述程序,并通过对日志的分析展示了建连过程。这些分析涵盖了信令服务连接和SDP交互、ICE候选收集与优先级排序、ICE 连通性检查各子阶段、NAT穿透尝试及最佳候选者对确定,以及数据通道打开和数据传输。希望这样的分析可以帮助大家更深刻的理解和体会建连过程。
WebRTC网络结构和建连就先讲到这里,后面的系列文章中,我们会开始聚焦WebRTC技术栈的另外一个主要方面:音视频质量,包括编码器以及媒体流处理等。
本文涉及的Go源码在这里可以下载到 – https://github.com/bigwhite/experiments/blob/master/webrtc-first-lesson/part2
Gopher部落知识星球在2024年将继续致力于打造一个高品质的Go语言学习和交流平台。我们将继续提供优质的Go技术文章首发和阅读体验。同时,我们也会加强代码质量和最佳实践的分享,包括如何编写简洁、可读、可测试的Go代码。此外,我们还会加强星友之间的交流和互动。欢迎大家踊跃提问,分享心得,讨论技术。我会在第一时间进行解答和交流。我衷心希望Gopher部落可以成为大家学习、进步、交流的港湾。让我相聚在Gopher部落,享受coding的快乐! 欢迎大家踊跃加入!
著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。
Gopher Daily(Gopher每日新闻) – https://gopherdaily.tonybai.com
我的联系方式:
商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。
© 2024, bigwhite. 版权所有.
2024-12-23 23:02:42
本文永久链接 – https://tonybai.com/2024/12/23/convert-github-issue-to-markdown-with-issue2md
到2024年底,不论你是否承认,AI时代都已经到来!近两个月,三大顶级商业AI模型巨头:Claude Sonnet 3.5、Google Gemini 2.0 Flash Experimental以及ChatGPT o3你方唱罢我登场,好不热闹!
作为走在AI应用前沿的程序员,利用AI辅助自己提高学习和工作实践的效率都是必不可少的。在使用AI的过程中,我们经常需要向其提供一些文档资料,对于文字资料,AI更偏爱TXT、Markdown、PDF等格式的文件。部署在Vercel上的MarkdownDown支持输入网页URL并将其转换为Markdown,而微软开源的MarkItdown则能将多种格式(pdf、ppt、word、html、zip等)转换为Markdown。这些工具在实践中帮助我们实现对AI的快速“投喂”。
然而,一些资料,如GitHub Issues,尚不能通过上述工具方便地转换为干净的、无额外干扰内容的Markdown或其他适合投喂给AI的格式。受到MarkdownDown的启发,我思考是否可以将GitHub Issues转换为Markdown,最终促成了issue2md这个想法。该工具旨在简化GitHub Issues与Markdown之间的转换过程,使得开发者可以更高效地利用AI理解Github issue中的内容,包括用户讨论中的一些观点和想法。
三个月前,我利用AI完成了issue2md这个小工具,我自己甚至没有写下一行代码。我仅仅对其提出一个小小的要求,那就是不要依赖任何第三方包,仅可以依赖Go标准库。在这三个月中,该工具给了我很大的帮助,将由它生成的Github Issue对应的Markdown文档投喂给AI后,可以让我快速理解Github issue的要点,尤其是那些历经几年讨论,积累了数百条comment的issue!
这里我将issue2md放到github上供大家下载使用,也希望能给大家带去相同的帮助。
下面简单介绍一下issue2md的用法。
issue2md项目有两个工具,或者说两种使用模式,一种是命令行模式,使用issue2md这个命令行工具。另外一种则是Web模式,使用issue2mdweb这个工具。
如果你喜欢命令行模式,那么你只需要使用下面命令安装issue2md即可:
$go install github.com/bigwhite/issue2md/cmd/issue2md@latest
issue2md cli程序的使用方法非常简单:
Usage: issue2md issue-url [markdown-file]
Arguments:
issue-url The URL of the GitHub issue to convert.
markdown-file (optional) The output markdown file.
它的第一个参数是github issue的URL。以Go 1.24版本json包增加对omitzero的支持的issue为例,它的url是https://github.com/golang/go/issues/45669,我们原封不动的将其作为issue2md的第一个参数执行:
$issue2md https://github.com/golang/go/issues/45669
Issue and comments saved as Markdown in file golang_go_issue_45669.md
issue2md cli默认会生成一个命名格式如下的文件:
{owner}_{repo}_issue_number.md
其内容使用markdown编辑器打开并渲染后将呈现如下的效果:
当然你也可以通过传入第二个命令行参数,作为最终生成的markdown的文件名!
如果你不喜欢命令行模式,你可以使用issue2mdweb提供的Web模式。最简单的启动一个issue2mdweb服务的方法就是利用我发布到Docker hub上的issue2md的公共镜像,你可以像下面这样在本地或你的私有云里运行一个issue2mdweb服务:
$docker run -d -p 8080:8080 bigwhite/issue2mdweb
然后用你的浏览器打开http://{host}:8080这个地址,你将看到如下的页面:
在中间的文本框中输入你要转换的Github issue地址,比如前面的https://github.com/golang/go/issues/45669,点击“Convert”,你的浏览器就会自动将转换后的Markdown文件下载到你的本地,文件命名和issue2md cli的默认命名格式一致!
如果你不想使用Docker运行,你可以自行下载issue2md代码并编译,也可以使用scripts中的命令将issue2mdweb安装为一个Systemd unit服务!
这里要注意的是,issue2md使用了Go标准口实现了对Github API的访问且没有使用任何账号信息,它仅适合将Public仓库的issue转换为Markdown,并且由于Github对API调用的限速,你在使用issue2md时不能过于频繁!此外,你若发现issue2md的bug或者你有什么新的想法,欢迎在issue2md仓库中提出你宝贵的issue。
最后打个“广告”,根据极客时间的专栏推广计划,我在春节前会为“Go语言第一课”专栏续写五篇文章,其中的第一篇“Go测试的5个使用建议”已经上线。
无论你是“Go语言第一课”的学员,还是首次听说这门专栏的小伙伴,我都欢迎你阅读这些文章,希望这些专栏文章能你带去新的收获!也欢迎你将阅读后的感受在评论区分享出来!
Gopher部落知识星球在2024年将继续致力于打造一个高品质的Go语言学习和交流平台。我们将继续提供优质的Go技术文章首发和阅读体验。同时,我们也会加强代码质量和最佳实践的分享,包括如何编写简洁、可读、可测试的Go代码。此外,我们还会加强星友之间的交流和互动。欢迎大家踊跃提问,分享心得,讨论技术。我会在第一时间进行解答和交流。我衷心希望Gopher部落可以成为大家学习、进步、交流的港湾。让我相聚在Gopher部落,享受coding的快乐! 欢迎大家踊跃加入!
著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。
Gopher Daily(Gopher每日新闻) – https://gopherdaily.tonybai.com
我的联系方式:
商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。
© 2024, bigwhite. 版权所有.
2024-12-17 05:58:05
本文永久链接 – https://tonybai.com/2024/12/17/go-1-24-foresight-part2
在上一篇文章中,我们介绍了即将于2025年2月发布的Go 1.24版本在语法、编译器和运行时方面的主要变化。本文将继续承接上文,重点介绍Go 1.24在工具链和标准库方面的重要更新,供大家参考。
我们日常编写Go项目代码时常常会依赖一些使用Go编写的工具,比如golang.org/x/tools/cmd/stringer或github.com/kyleconroy/sqlc。我们希望所有项目合作者都使用相同版本的工具,以避免在不同时间、不同环境中的输出不同的结果。因此,Go社区希望通过go.mod将工具的版本以及依赖管理起来。
在Go 1.24版本之前,Go Wiki推荐tools.go的一种来自社区的最佳实践,阐述这种实践的最好的一个示例来自Go modules by example中的一个文档:”Tools as dependencies“,其大致思路是将项目依赖的Go工具以“项目依赖”的方式存放到tools.go文件(放到go module根目录下)中,以golang.org/x/tools/cmd/stringer为例,tools.go的内容大致如下:
//go:build tools
package tools
import (
_ "golang.org/x/tools/cmd/stringer"
)
然后在同一目录下安装stringer或直接go run:
$go install golang.org/x/tools/cmd/stringer
在安装stringer时,go.mod会记录下对stringer的依赖以及对应的版本,后续go.mod提交到项目repo中,所有项目成员就都可以使用相同版本的Stringer了。
tools.go实践虽然能解决问题,但这种方式还是存在一些不便:
Go开发者期望工具依赖也能够无缝地与其他项目依赖(包依赖)统一管理,并纳入go.mod的版本控制体系。
为此,该提案设计并实现了下面几点以满足开发者的上述述求:
我们来看一个示例,首先我们初始化一个module:
$ gotip mod init demo
go: creating new go.mod: module demo
$ cat go.mod
module demo
go 1.24
编辑go.mod,加入下面内容:
$ cat go.mod
module demo
go 1.24
tool golang.org/x/tools/cmd/stringer
安装tool前需要go get它的依赖,否则go install会报错:
$gotip install tool
no required module provides package golang.org/x/tools/cmd/stringer; to add it:
go get golang.org/x/tools/cmd/stringer
$gotip get golang.org/x/tools/cmd/stringer
go: downloading golang.org/x/tools v0.28.0
go: downloading golang.org/x/sync v0.10.0
go: downloading golang.org/x/mod v0.22.0
go: added golang.org/x/mod v0.22.0
go: added golang.org/x/sync v0.10.0
go: added golang.org/x/tools v0.28.0
$ cat go.mod
module demo
go 1.24
tool golang.org/x/tools/cmd/stringer
require (
golang.org/x/mod v0.22.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/tools v0.28.0 // indirect
)
我们看到:go.mod中require了stringer的依赖。
接下来,我们便可以用go install安装stringer了:
$ ls -l `which stringer` // old版本的stringer
-rwxr-xr-x 1 root root 6500561 1月 23 2024 /root/go/bin/stringer
$ gotip install tool
$ ls -l `which stringer`
-rwxr-xr-x 1 root root 7303970 12月 9 21:41 /root/go/bin/stringer
后续要更新stringer版本,可以直接使用go get -u:
$gotip get -u golang.org/x/tools/cmd/stringer
此外,除了手工编辑go.mod,添加依赖的tool外,我们也可以直接使用go get -tool像go.mod中添加依赖的tool,它们在效果上是等价的:
// 重置go.mod到最初状态
# cat go.mod
module demo
go 1.24
// 执行go get -tool
$gotip get -tool golang.org/x/tools/cmd/stringer
go: added golang.org/x/mod v0.22.0
go: added golang.org/x/sync v0.10.0
go: added golang.org/x/tools v0.28.0
$ cat go.mod
module demo
go 1.24
tool golang.org/x/tools/cmd/stringer
require (
golang.org/x/mod v0.22.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/tools v0.28.0 // indirect
)
使用stringer时也无需手工敲入那么长的命令(go run golang.org/x/tools/cmd/stringer),只需使用gotip tool stringer即可:
$ gotip tool stringer
Usage of stringer:
stringer [flags] -type T [directory]
stringer [flags] -type T files... # Must be a single package
For more information, see:
https://pkg.go.dev/golang.org/x/tools/cmd/stringer
Flags:
-linecomment
use line comment text as printed text when present
-output string
output file name; default srcdir/<type>_string.go
-tags string
comma-separated list of build tags to apply
-trimprefix prefix
trim the prefix from the generated constant names
-type string
comma-separated list of type names; must be set
go tool stringer就相当于go run golang.org/x/tools/cmd/[email protected]了(注:v0.28.0是当前golang.org/x/tools的版本)。
tool directive和go工具链做了很好的融合,除了上面的命令外,还支持:
到这里,屏幕前的你可能会问一个问题:如果本地多个项目依赖同一个工具的不同版本,比如golangci-lint的v1.62.2和v1.62.0时,那么两个项目安装的golangci-lint是否会相互覆盖和影响呢?我们来验证一下,下面建立两个项目:tool-directive1和tool-directive2。
.
├── tool-directive1/
│ ├── go.mod
│ └── go.sum
└── tool-directive2/
├── go.mod
└── go.sum
我们先在tool-directive1下面执行下面命令添加对golangci-lint的依赖:
$gotip get -tool github.com/golangci/golangci-lint/cmd/golangci-lint
go: downloading github.com/golangci/golangci-lint v1.62.2
go: downloading github.com/gofrs/flock v0.12.1
go: downloading github.com/fatih/color v1.18.0
... ...
然后在同一个目录下,使用gotip tool golangci-lint执行该工具,查看其版本:
$ gotip tool golangci-lint --version
golangci-lint has version v1.62.2 built with devel go1.24-c8fb6ae6 Sun Dec 8 15:34:47 2024 +0000 from (unknown, modified: ?, mod sum: "h1:b8K5K9PN+rZN1+mKLtsZHz2XXS9aYKzQ9i25x3Qnxxw=") on (unknown)
我们看到tool-directive1依赖了v1.62.2版本的golangci-lint。不过你在执行上述命令时可能会注意到,这个命令的执行非常耗时,可能需要10~20s才能出结果。如果你再执行一次,它就可以瞬间输出结果,为什么会这样的?稍后我们给出答案。
现在我们切换到tool-directive2目录下,执行下面命令添加对golangci-lint v1.62.0版本的依赖:
$gotip get -tool github.com/golangci/golangci-lint/cmd/[email protected]
然后在同一个目录下,使用gotip tool golangci-lint执行该工具,查看其版本:
$gotip tool golangci-lint --version
golangci-lint has version v1.62.0 built with devel go1.24-c8fb6ae6 Sun Dec 8 15:34:47 2024 +0000 from (unknown, modified: ?, mod sum: "h1:/G0g+bi1BhmGJqLdNQkKBWjcim8HjOPc4tsKuHDOhcI=") on (unknown)
我们看到tool-directive2下得到的是v1.62.0版本的golangci-lint。并且我们会遇到同样的现象:第一次执行很慢,第二次执行就会瞬间出结果。
再回到tool-directive1下,看看它依赖的golangci-lint是否被覆盖了:
$gotip tool golangci-lint --version
golangci-lint has version v1.62.2 built with devel go1.24-c8fb6ae6 Sun Dec 8 15:34:47 2024 +0000 from (unknown, modified: ?, mod sum: "h1:b8K5K9PN+rZN1+mKLtsZHz2XXS9aYKzQ9i25x3Qnxxw=") on (unknown)
我们发现:两个项目下依赖的版本各自独立,并不会相互覆盖。
这其中的缘由又是什么呢?为什么使用go tool golangci-lint第一次执行会慢,而后续的执行就会飞快呢?下面的issue将回答这个问题。
Go 1.24 之前,cmd/go仅缓存编译后的包文件(build actions),而不缓存链接后的二进制文件(link actions)。不缓存二进制文件很大原因在于二进制文件比单个包对象文件大得多,并且它们不像包文件那样被经常重用。
不过上述1.1中,让go支持对依赖工具的管理以及让go tool支持自定义工具执行的issue让这个issue最终被纳入Go 1.24。该issue实现后,go run以及像上面那种go tool golangci-lint(本质上也是go run github.com/golangci/golangci-lint/cmd/[email protected])的编译链接的结果会被缓存到go build cache中。这也是上面不同项目依赖同一工具不同版本时不会相互覆盖以及首次使用go tool执行依赖工具较慢的原因,第一次go tool执行会执行编译链接过程,之后的运行就会从缓存中直接找到缓存的文件并执行了。
由于这个issue会显著增大go build cache的磁盘空间占用,该issue也规定了,在缓存执行定期清理的时候,可执行文件缓存会优先于包缓存被优先清理掉。
在Go 1.18及之后的版本中,cmd/go工具链在构建二进制文件时会嵌入依赖版本信息和VCS(版本控制系统)信息,这使得开发者可以更容易地追踪二进制文件的来源。然而,当使用go build命令构建主模块时,主模块的版本信息并不会被记录,而是显示为(devel),这导致开发者需要使用外部构建脚本或-ldflags来手动设置版本信息。相比之下,go install命令会正确记录主模块的版本信息。
该issue就旨在让go build命令也能像go install一样,自动嵌入主模块的版本信息,从而避免开发者依赖外部构建脚本。
落地后,Go 1.24的go build命令会在编译后的二进制文件中包含版本信息。如果本地VCS(版本控制系统)标签可用,主模块的版本将从该标签中设置。如果没有本地VCS标签可用,则会生成一个伪版本(pseudo-version),通常包含时间戳和提交哈希。 此外,为了避免与已发布的版本混淆,go build还会在伪版本中添加一些特殊的标识符,例如devel,以表明这是一个本地构建的版本。如果有未提交的VCS更改,则会附加一个+dirty后缀。
使用-buildvcs=false标志可以省略二进制文件中的版本控制信息。
下面对比一下Go 1.24版本之前与Go 1.24版本在go build时生成的版本信息的差异:
以Go 1.23为例,其构建和安装的stringer的版本信息如下:
$go version -m `which stringer`
/root/go/bin/stringer: go1.23.0
... ...
而使用go1.24的build构建的stringer的版本信息如下:
$go version -m tool-directive1/bin/stringer
tool-directive1/bin/stringer: devel go1.24-c8fb6ae6 Sun Dec 8 15:34:47 2024 +0000
... ...
估计Go社区很少有人用过GOCACHEPROG,即便在Go 1.21版本之后,它是以实验特性的形式提供的,通过GOEXPERIMENT=cacheprog启用。这个特性是由Go语言元老Brad Fitzpatrick提出的,其主issue编号是59719。
我们知道:Go语言的cmd/go工具已经具备了强大的缓存支持,但其缓存机制仅限于基于文件系统的缓存。这种缓存方式在某些场景下效率不高,尤其是在CI(持续集成)环境中,用户通常需要将GOCACHE目录打包和解压缩,这往往比CI操作本身还要慢。此外,用户可能希望利用位于网络上的共享缓存(比如S3)或公司内部的P2P缓存协议来提高缓存效率,但这些功能并不适合直接集成到cmd/go工具中。
为了解决上述问题,Brad Fitzpatrick提出了一个新的环境变量GOCACHEPROG,类似于现有的GOCACHE变量。通过设置GOCACHEPROG,用户可以指定一个外部程序,该程序将作为子进程运行,并通过标准输入/输出来与cmd/go工具进行通信。cmd/go工具将通过这个接口与外部缓存程序交互,外部程序可以根据需要实现任意的缓存机制和策略。
为此,Bradfitz在issue 59719中给出了交互的协议设计。cmd/go工具与外部缓存程序之间的通信基于JSON格式的消息。消息分为请求(ProgRequest)和响应(ProgResponse)。请求包括命令类型、操作ID(ActionID)、对象ID(ObjectID)等。响应则包括缓存命中与否、对象的磁盘路径等信息。
其中请求的命令类型有如下几种:
对于put请求,cmd/go工具会将对象的二进制数据通过base64编码后发送给外部程序。对于get请求,外部程序返回对象的磁盘路径。
在\$GOROOT/src/cmd/go/internal/cache/prog.go文件中可以看到具体协议相关的结构。
Bradfitz还给出了一个外部cache的样例程序go-tool-cache,还有开发者fork了该样例程序,将它改造为以S3为后端cache的外部缓存程序。感兴趣的童鞋,可以按照这些样例程序的说明试验一下外部缓存功能。
在Go语言中,go get命令用于从远程代码仓库获取依赖包。通常,这些依赖包的导入路径是通过HTTP请求获取的,服务器会返回一个包含元标签(meta tag)的HTML页面,指示如何获取该包的源代码。然而,对于需要身份验证的私有仓库,go get无法直接工作,因为go get使用的是net/http.DefaultClient,它不知道如何处理需要身份验证的URL。具体来说,当go get尝试获取一个私有仓库的URL时,由于没有提供身份验证信息,服务器会返回401或403错误,导致go get无法继续执行。这个问题在企业环境中尤为常见,因为许多公司使用私有代码托管服务,而这些服务通常需要身份验证。
issue 26232为上述情况提供了一种方案,让go get能够支持需要身份验证的私有仓库,使得用户可以通过go get命令获取私有仓库中的代码:
$go get git.mycompany.com/private-repo
即使https://git.mycompany.com/private-repo需要身份验证,go get也能够正常工作。
方案采用了一种类似于Git凭证助手的机制,并通过新增的Go环境变量GOAUTH来指定一个或多个认证命令。go get在执行时会调用这些命令,获取身份验证信息,并在后续的HTTP请求中使用这些信息。
GOAUTH环境变量可以包含一个或多个认证命令,每个命令由空格分隔的参数列表组成,命令之间用分号分隔。go get会在每次需要进行HTTP请求时,首先检查缓存中的认证信息,如果没有匹配的认证信息,则会调用GOAUTH命令来获取新的认证信息。
通过go help goauth可以查看GOAUTH的详细用法,在Go 1.24中它支持如下认证命令:
Response = { CredentialSet } .
CredentialSet = URLLine { URLLine } BlankLine { HeaderLine } BlankLine .
URLLine = /* URL that starts with "https://" */ '\n' .
HeaderLine = /* HTTP Request header */ '\n' .
BlankLine = '\n' .
Go 1.24版本之前,Go已经支持了go test -json命令,旨在为测试过程提供结构化的JSON输出,便于工具解析和处理测试结果。然而,当测试或导入的包在构建过程中失败时,构建错误信息会与测试的JSON输出交织在一起,导致工具难以准确地将构建错误与受影响的测试包关联起来。这增加了工具处理go test -json输出的复杂性。
为了解决这个问题,issue 62067提出了为go build命令(包括go install)添加-json标志的建议,以便生成与go test -json兼容的结构化JSON输出。go test -json也得到了优化,现在在test时出现构建错误时,go test -json也会以json格式输出构建错误信息,与test结果的json内容可以很好的融合在一起。当然,你也可以通过GODEBUG=gotestjsonbuildtext=1继续让go test -json输出文本格式的构建错误信息,以保持与Go 1.24之前的情况一致。
Go标准库向来是添加新特性的大户,不过鉴于变化太多,下面我们仅列举一些主要的变化点。
关于这个变化点,我在《JSON包新提案:用“omitzero”解决编码中的空值困局》一文中有详细说明,请移步阅读,这里不赘述了。
weak包和weak指针是Go团队在设计和实现unique包时的“副产物”,Go团队认为weak指针可以给大家带来更灵活的内存管理机制,于是将其从internal中提到标准库中。我之前的《Go weak包前瞻:弱指针为内存管理带来新选择》一文对weak包有详细说明,请移步阅读。
在Go 1.24开发周期中,Go密码学小组与Russ Cox根据开发者日益增多的密码学合规性(满足FIPS 140)的需求反馈,决定对Go的加密库进行改造,以符合申请进行FIPS 140标准认证的要求。有关这个认证的issue和改动点(cl)都很多,大家可以阅读我的《走向合规:Go加密库对FIPS 140的支持》一文了解详情。
读过我的《Go开发者的密码学导航:crypto库使用指南》一文的读者都知道:Go密码学团队维护的密码学包分布在Go标准库crypto目录和golang.org/x/crypto下面。Go密码学小组负责人Roland Shoemaker认为当前这种”分割”的状态会带来一些问题:
为此Shoemaker提议了一个将x/crypto下的包到标准库crypto目录下的方案,以简化Go语言加密库的管理和维护,提高用户对这些库的信任和使用率,方案的大致思路和步骤如下:
基于上述方案,Go 1.24版本中,Go密码学团队完成了hkdf、pbkdf2、sha3和mlkem等包的迁移。当然这次迁移与Go密码学包要进行FIPS 140-3认证也有着直接的联系。
这里面值得一提的是mklem包,它实现了NIST FIPS 203中指定的抗量子密钥封装方法ML-KEM(以前称为Kyber),也是Go密码学包中第一个后量子密码学包。
目录遍历漏洞(Directory Traversal Vulnerabilities)和符号链接遍历漏洞(Symlink Traversal Vulnerabilities)是常见的安全漏洞。攻击者通过提供相对路径(如”../../../etc/passwd”)或创建符号链接,诱使程序访问其本不应访问的文件,从而导致安全问题。例如,CVE-2024-3400 是一个最近的真实案例,展示了目录遍历漏洞如何导致远程代码执行。
在Go中,虽然可以通过 filepath.IsLocal等函数来验证文件名,但防御符号链接遍历攻击较为困难。现有的os.Open和os.Create等函数在处理不受信任的文件名时,容易受到这些攻击的影响。
为了解决这些问题,issue 67002提出了在os包中添加几个新的函数和方法,以安全地打开文件并防止目录遍历和符号链接遍历攻击。
最初该提案提出新增一些安全访问文件系统的API函数,在讨论过程中,Russ Cox 提出了一个更为简洁的方案,避免了引入大量新的 API,而是通过引入一个新的类型 Dir 来表示受限的文件系统根目录。这个方案最终奠定了该提案的最终实现。
最终Go在os包中引入了一个新的Root类型,并基于该类型提供了在特定目录内执行文件系统操作的能力。os.OpenRoot函数打开一个目录并返回一个os.Root。os.Root上的方法仅限于在该目录内操作,并且不允许路径引用目录外的位置,包括跟随符号链接指向目录外的路径。下面是一些Root类型的常用方法:
下面我们用一个示例对比一下通过os.Root进行的文件系统操作与传统文件系统操作的差异:
// go1.24-foresight/stdlib/osroot/main.go
package main
import (
"fmt"
"os"
)
func main() {
// 使用 os.Root 访问相对路径
root, err := os.OpenRoot(".") // 打开当前目录作为根目录
if err != nil {
fmt.Println("Error opening root:", err)
return
}
defer root.Close()
// 尝试访问相对路径 "../passwd"
file, err := root.Open("../passwd")
if err != nil {
fmt.Println("Error opening file with os.Root:", err)
} else {
fmt.Println("Successfully opened file with os.Root")
file.Close()
}
// 传统的 os.OpenFile 方式
// 尝试访问相对路径 "../passwd"
file2, err := os.OpenFile("../passwd", os.O_RDONLY, 0644)
if err != nil {
fmt.Println("Error opening file with os.OpenFile:", err)
} else {
fmt.Println("Successfully opened file with os.OpenFile")
file2.Close()
}
}
运行上述代码,我们得到:
$gotip run main.go
Error opening file with os.Root: openat ../passwd: path escapes from parent
Successfully opened file with os.OpenFile
我们看到:当代码通过os.Root返回的目录来尝试访问相对路径”../passwd”时,由于os.Root限制了操作仅限于根目录内,因此会返回错误。
从安全角度来看,Go 1.24之后,建议搭建多多使用这种安全操作文件系统的方式,如果你的文件操作都局限在一个目录下。
Go 1.24版本之前,Go提供了runtime.SetFinalizer函数用于对象的终结处理。然而,SetFinalizer的使用存在许多问题和限制,Michael Knyszek总结了下面几点:
后面两个问题主要源于SetFinalizer允许对象复活(object resurrection),这使得对象的清理变得复杂且不可靠。
为了解决上述问题,,Michael Knyszek提出了一个新的API runtime.AddCleanup,并建议正式弃用runtime.SetFinalizer。AddCleanup的设计目标是解决SetFinalizer的诸多问题,特别是避免对象复活,从而允许对象的及时清理,并支持对象的循环清理。
AddCleanup函数的原型如下:
func AddCleanup[T, S any](ptr *T, cleanup func(S), arg S) Cleanup
AddCleanup函数将一个清理函数附加到ptr。当ptr不再可达时,运行时会在一个单独的goroutine中调用 cleanup(arg)。
AddCleanup的一个典型的用法如下:
f, _ := Open(...)
runtime.AddCleanup(f, func(fd uintptr) { syscall.Close(fd) }, f.Fd())
通常,ptr是一个包装底层资源的对象(例如上面典型用法中的那个包装操作系统文件描述符的File对象),arg是底层资源(例如操作系统文件描述符),而清理函数释放底层资源(例如,通过调用close系统调用)。
AddCleanup对ptr的约束很少,支持为同一个指针附加多个清理函数。不过,如果ptr可以从cleanup或arg中可达,ptr将永远不会被回收,清理函数也永远不会运行。作为一种简单的保护措施,如果arg等于ptr,AddCleanup会引发panic。清理函数的运行顺序没有指定。特别是,如果几个对象相互指向并且同时变得不可达,它们的清理函数都可以运行,并且可以以任何顺序运行。即使对象形成一个循环也是如此。
cleanup(arg)调用并不总是保证运行,特别是它不保证在程序退出之前能运行。
清理函数可能在对象变得不可达时立即运行。为了正确使用清理函数,程序必须确保对象在清理函数安全运行之前保持可达。存储在全局变量中的对象,或者可以通过从全局变量跟踪指针找到的对象,是可达的。函数参数或方法接收者可能在函数最后一次提到它的地方变得不可达。为了确保清理函数不会过早调用,我们可以将对象传递给KeepAlive函数,以保证对象在保持可达的最后一个点之后依然可达。
到这里,也许一些读者想到了RAII(Resource Acquisition Is Initialization),RAII的核心思想是将资源的获取和释放与对象的生命周期绑定在一起,从而确保资源在对象不再使用时能够被正确释放。似乎AddCleanup可以用于实现Go版本的RAII,下面是一个示例:
// go1.24-foresight/stdlib/addcleanup/main.go
package main
import (
"fmt"
"os"
"runtime"
"syscall"
"time"
)
type FileResource struct {
file *os.File
}
func NewFileResource(filename string) (*FileResource, error) {
file, err := os.Open(filename)
if err != nil {
return nil, err
}
// 使用 AddCleanup 注册清理函数
fd := file.Fd()
runtime.AddCleanup(file, func(fd uintptr) {
fmt.Println("Closing file descriptor:", fd)
syscall.Close(int(fd))
}, fd)
return &FileResource{file: file}, nil
}
func main() {
fileResource, err := NewFileResource("example.txt")
if err != nil {
fmt.Println("Error opening file:", err)
return
}
// 模拟使用 fileResource
_ = fileResource
fmt.Println("File opened successfully")
// 当 fileResource 不再被引用时,AddCleanup 会自动关闭文件
fileResource = nil
runtime.GC() // 强制触发 GC,以便清理 fileResource
time.Sleep(time.Second * 5)
}
运行上述代码得到如下结果:
$gotip run main.go
File opened successfully
Closing file descriptor: 3
的确,在Go中,runtime.AddCleanup可以用来模拟RAII机制,但与传统的RAII有一些不同,在Go中,资源获取通常是通过显式的函数调用来完成的,例如打开文件等,而不是像C++那样在构造函数中隐式完成。并且,资源的释放由Go GC回收对象时触发。如果要实现C++那样的RAII,需要我们自行做一些封装。
在Go语言中,基准测试(benchmarking)是通过testing.B类型的b.N来实现的。b.N表示基准测试需要执行的迭代次数。然而,这种设计存在一些问题:
为了解决上述问题,Austin Clements提议在testing.B中添加一个新的方法Loop,并鼓励开发者使用Loop而不是b.N:
func (b *B) Loop() bool
func Benchmark(b *testing.B) {
...(setup)
for b.Loop() {
// … benchmark body …
}
...(cleanup)
}
显然新Loop方法以及基于新Loopfang方法的“新Benchmark”函数有如下优点:
这里也强烈建议大家在Go 1.24及以后版本中,使用基于B.Loop的新基准测试函数。
在Go语言中,测试并发代码一直是一个具有挑战性的任务。传统的测试方法通常依赖于真实的系统时钟和同步机制,这会导致测试变得缓慢且容易出现不确定性(即“flaky”测试)。例如,测试一个带有超时机制的并发缓存时,测试代码可能需要等待几秒钟来验证缓存条目是否在预期时间内过期。这种等待不仅增加了测试的执行时间,还可能导致测试在某些情况下失败,尤其是在CI系统负载较高或执行环境不稳定的情况下。
为了解决这些问题,Go社区提出了一个新的testing/synctest包,旨在简化并发代码的测试。该包的核心思想是通过使用虚拟时钟和goroutine组(也称为气泡(bubble)来控制并发代码的执行,从而使测试既快速又可靠。下面是synctest包的API:
func Run(f func()) {
synctest.Run(f)
}
func Wait() {
synctest.Wait()
}
我们看到synctest包对外仅暴露两个公开函数。
Run函数在一个新的goroutine中执行f函数,并创建一个独立的goroutine组(气泡),确保所有相关的goroutine都在虚拟时钟的控制下执行。气泡内的goroutine不能与气泡外的goroutine直接交互,否则会引发panic。如果所有goroutine都被阻塞且没有定时器被调度,Run会引发panic。Run 会在气泡中的所有goroutine退出后返回。
Wait函数调用后将阻塞,直到当前气泡中的所有其他goroutine都处于持久阻塞状态。该函数用于确保在虚拟时间推进后,所有相关的goroutine都已经完成其工作。即确保在测试继续之前所有后台goroutine都已空闲或退出。如果从非气泡的goroutine调用Wait,或者同一气泡中的两个goroutine同时调用Wait,会引发panic。阻塞在系统调用或外部事件(如网络操作)的goroutine不是持久阻塞的,Wait不会等待这些goroutine。
这里再明确一下上面API说明中提到的各种概念:
Run函数创建的goroutine及其间接启动的所有goroutine形成一个独立的“气泡”。气泡内的goroutine使用虚拟时钟,并且气泡内的所有操作(如通道、定时器等)都与该气泡关联。气泡内的goroutine不能与气泡外的goroutine直接交互。
虚拟时钟的初始时间为2000-01-01 00:00:00 UTC。每个气泡有一个虚拟时钟,它只有在所有goroutine都处于阻塞状态时才会推进。这意味着测试代码可以精确控制时间的流逝,而不会受到真实系统时钟的限制。
一个goroutine如果只能被气泡内的另一个goroutine解除阻塞,则称其为持久阻塞。以下操作会使goroutine持久阻塞:
- 在气泡内向通道发送或接收数据
- 在select语句中,每个case都是气泡内的通道
- sync.Cond.Wait
- time.Sleep
下面是一个使用testing/synctest进行测试的简单示例,我们有一个Cache结构:
// go1.24-foresight/stdlib/synctest/cache.go
package main
import (
"sync"
"time"
)
// Cache 是一个泛型并发缓存,支持任意类型的键和值。
type Cache[K comparable, V any] struct {
mu sync.Mutex
items map[K]cacheItem[V]
expiry time.Duration
creator func(K) V
}
// cacheItem 是缓存中的单个条目,包含值和过期时间。
type cacheItem[V any] struct {
value V
expiresAt time.Time
}
// NewCache 创建一个新的缓存,带有指定的过期时间和创建新条目的函数。
func NewCache[K comparable, V any](expiry time.Duration, f func(K) V) *Cache[K, V] {
return &Cache[K, V]{
items: make(map[K]cacheItem[V]),
expiry: expiry,
creator: f,
}
}
// Get 返回缓存中指定键的值,如果键不存在或已过期,则创建新条目。
func (c *Cache[K, V]) Get(key K) V {
c.mu.Lock()
defer c.mu.Unlock()
// 检查缓存中是否存在该键
item, exists := c.items[key]
// 如果键存在且未过期,返回缓存的值
if exists && time.Now().Before(item.expiresAt) {
return item.value
}
// 如果键不存在或已过期,创建新条目
value := c.creator(key)
c.items[key] = cacheItem[V]{
value: value,
expiresAt: time.Now().Add(c.expiry),
}
return value
}
上述代码实现了一个简单的并发缓存,支持泛型键和值,并且具有过期机制。通过使用sync.Mutex来保护对缓存条目的并发访问,确保了线程安全。Get方法在键不存在或已过期时,会调用creator函数创建新条目,并更新缓存。
下面是对上面Cache结构进行并发测试的代码:
// go1.24-foresight/stdlib/synctest/cache_test.go
package main
import (
"testing"
"testing/synctest"
"time"
)
func TestCacheEntryExpires(t *testing.T) {
synctest.Run(func() {
count := 0
c := NewCache(2*time.Second, func(key string) int {
count++
return count
})
// Get an entry from the cache.
if got, want := c.Get("k"), 1; got != want {
t.Errorf("c.Get(k) = %v, want %v", got, want)
}
// Verify that we get the same entry when accessing it before the expiry.
time.Sleep(1 * time.Second)
synctest.Wait()
if got, want := c.Get("k"), 1; got != want {
t.Errorf("c.Get(k) = %v, want %v", got, want)
}
// Wait for the entry to expire and verify that we now get a new one.
time.Sleep(3 * time.Second)
synctest.Wait()
if got, want := c.Get("k"), 2; got != want {
t.Errorf("c.Get(k) = %v, want %v", got, want)
}
})
}
通过使用synctest.Run和synctest.Wait,上述测试代码能够在虚拟时钟的控制下验证Cache的过期机制。synctest.Run创建了一个独立的goroutine组,确保所有相关的goroutine都在虚拟时钟的控制下执行。synctest.Wait确保在虚拟时间推进后,所有相关的goroutine都已经完成其工作。
使用gotip执行该测试:
$GOEXPERIMENT=synctest gotip test -v
=== RUN TestCacheEntryExpires
--- PASS: TestCacheEntryExpires (0.00s)
PASS
ok demo 0.002s
我们可以瞬间得到结果,而无需等待代码中的Sleep秒数。
slog包添加包级变量slog.DiscardHandler (类型为slog.Handler ),它将丢弃所有日志输出。
下面是五个返回迭代器的新增函数,以strings包为例:
- func Lines(s string) iter.Seq[string]
返回一个迭代器,遍历字符串s中以换行符结尾的行。
- func SplitSeq(s, sep string) iter.Seq[string]
返回一个迭代器,遍历s中由sep分隔的所有子字符串。
- func SplitAfterSeq(s, sep string) iter.Seq[string]
返回一个迭代器,遍历s中在每个sep实例之后分割的子字符串。
- func FieldsSeq(s string) iter.Seq[string]
返回一个迭代器,遍历s中由空白字符(由unicode.IsSpace定义)分隔的子字符串。
- func FieldsFuncSeq(s string, f func(rune) bool) iter.Seq[string]
返回一个迭代器,遍历s中由满足f(c)的Unicode码点分隔的子字符串。
和weak包一样,HashTrieMap同样是实现unique包的副产品,但它的性能很好,在很多情况下都要比sync.Map快很多。于是Michael Knyszek使用HashTrieMap替换了sync.Map的底层实现。
当然,如果你不满意HashTrieMap的表现,你也可以使用GOEXPERIMENT=nosynchashtriemap恢复到sync.Map之前的实现。
在Go语言的net/http包中,HTTP/2的支持默认是通过TLS加密的连接来实现的,通常称为”h2″。然而,HTTP/2也可以在不加密的TCP连接上运行,这种模式被称为”h2c”(HTTP/2 Clear Text)。尽管golang.org/x/net/http2/h2c包提供了对h2c的支持,但这种支持并不直接集成到net/http包中,导致用户在使用h2c时需要进行复杂的配置和处理。因此,社区提出了将h2c支持直接集成到net/http包中的issue,以简化用户的使用体验。
直接集成h2c支持后,将使得Go语言的HTTP/2功能更加完整,用户可以更方便地在未加密的连接上使用HTTP/2。
Go语言在WebAssembly(Wasm)的支持方面已经有了一定的进展,特别是在Go 1.21版本引入了go:wasmimport指示符,使得Go代码可以调用Wasm宿主定义的函数。然而,目前仍然无法从Wasm宿主调用Go代码。这对于一些需要扩展功能的应用来说是一个限制,例如Envoy、Istio、VS Code等应用,它们允许通过调用Wasm编译的代码来扩展功能。但Go目前无法支持这些应用,因为Go编译的Wasm模块中唯一导出的函数是_start,对应于main包中的main函数。
但Go社区对导出Go函数为wasm有着迫切的需求,同时,导出函数到Wasm宿主也是实现GOOS=wasip2的必要条件(wasip2是WASI规范的预览2版本)。
于是issue 65199给出了导出Go函数到Wasm的落地方案。该issue提议在库模式下(即导出的Go函数供其他基于wasm运行时库开发的应用使用),重用-buildmode构建标志值c-shared,用于wasip1。它现在向编译器发出信号,要求用_initialize函数替换_start函数,该函数执行运行时和包的初始化:
$gotip help buildmode
... ...
-buildmode=c-shared
Build the listed main package, plus all packages it imports,
into a C shared library. The only callable symbols will
be those functions exported using a cgo //export comment.
On wasip1, this mode builds it to a WASI reactor/library,
of which the callable symbols are those functions exported
using a //go:wasmexport directive. Requires exactly one
main package to be listed.
... ...
新增一个编译器指示符go:wasmexport,用于向编译器发出信号,表明某个函数应该使用Wasm导出(Wasm export),在生成的Wasm二进制文件中导出。该指示符只能在GOOS=wasip1时使用,否则会导致编译失败。
//go:wasmexport name
其中name是导出函数的名称,该参数是必需的。该指示符只能用于函数,不能用于方法。
该issue由Johan Brandhorst提出,但最终是由CherryMui给出了最终实现,并且CherryMui还给出了一个应用go:wasmexport的example,这个example演示了go:wasmexport在库模式下的应用方法。例子代码较多,这里我做了一个裁剪,下面是裁剪后的代码和使用方法,大家可以参考一下。
示例的结构如下:
$tree -F ./wasmtest
./wasmtest
├── Makefile
├── go.mod
├── go.sum
├── testprog/
│ └── x.go
└── w.go
其中testprog/x.go中导出了一个Add函数:
// go1.24-foresight/wasmtest/testprog/x.go
package main
func init() {
println("init function called")
}
//go:wasmexport Add
func Add(a, b int64) int64 {
return a+b
}
func main() {
println("hello")
}
我们将x.go编译为x.wasm文件:
$GOARCH=wasm GOOS=wasip1 gotip build -buildmode=c-shared -o x.wasm ./testprog
然后在w.go中使用x.wasm中的Add函数:
// go1.24-foresight/wasmtest/w.go
package main
import (
"context"
"fmt"
"os"
"github.com/tetratelabs/wazero"
"github.com/tetratelabs/wazero/api"
"github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
)
func main() {
ctx := context.Background()
r := wazero.NewRuntime(ctx)
defer r.Close(ctx)
buf, err := os.ReadFile(os.Args[1])
if err != nil {
panic(err)
}
config := wazero.NewModuleConfig().
WithStdout(os.Stdout).WithStderr(os.Stderr).
WithStartFunctions() // don't call _start
wasi_snapshot_preview1.MustInstantiate(ctx, r)
m, err := r.InstantiateWithConfig(ctx, buf, config)
if err != nil {
panic(err)
}
// get export functions from the module
F := func(a int64, b int64) int64 {
exp := m.ExportedFunction("Add")
r, err := exp.Call(ctx, api.EncodeI64(a), api.EncodeI64(b))
if err != nil {
panic(err)
}
rr := int64(r[0])
fmt.Printf("host: Add %d + %d = %d\n", a,b,rr)
return rr
}
// Library mode.
entry := m.ExportedFunction("_initialize")
fmt.Println("Library mode: initialize")
_, err = entry.Call(ctx)
if err != nil {
panic(err)
}
fmt.Println("\nLibrary mode: call export functions")
println(F(5,6))
}
运行上述w.go,我们将得到以下预期结果:
$gotip run w.go ./x.wasm
Library mode: initialize
init function called
Library mode: call export functions
host: Add 5 + 6 = 11
11
本文详细介绍了即将发布的Go 1.24版本在工具链和标准库方面的重要新特性。这些新特性不仅简化了工具的使用,提升了开发体验,还增强了标准库的功能和安全性,特别是在加密、并发测试等方面。通过这些改进,Go语言将继续朝着更高效、更安全、更易用的方向发展。
本文涉及的源码可以在这里下载。
Gopher部落知识星球在2024年将继续致力于打造一个高品质的Go语言学习和交流平台。我们将继续提供优质的Go技术文章首发和阅读体验。同时,我们也会加强代码质量和最佳实践的分享,包括如何编写简洁、可读、可测试的Go代码。此外,我们还会加强星友之间的交流和互动。欢迎大家踊跃提问,分享心得,讨论技术。我会在第一时间进行解答和交流。我衷心希望Gopher部落可以成为大家学习、进步、交流的港湾。让我相聚在Gopher部落,享受coding的快乐! 欢迎大家踊跃加入!
著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。
Gopher Daily(Gopher每日新闻) – https://gopherdaily.tonybai.com
我的联系方式:
商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。
© 2024, bigwhite. 版权所有.
2024-12-16 07:45:31
本文永久链接 – https://tonybai.com/2024/12/16/go-1-24-foresight-part1
自2020年底撰写《Go 1.16版本新特性前瞻》以来,四年转瞬而逝。在这段时间里,每当Go的大版本开发进入新特性冻结(freeze)阶段,我都会为大家带来该版本的特性前瞻,旨在让大家更早地了解和实验这些新特性,从而在版本正式发布时能够准确评估是否应用它们。
11月末,Go 1.24的新特性开发已经冻结,我认为是时候对Go 1.24新特性进行前瞻了。本次前瞻将分为两篇进行,本文,也就是第一篇将讲解语法、编译器与运行时方面的变化,而第二篇将聚焦工具链和标准库。本次前瞻可以引导大家了解即将在明年3月份发布的Go 1.24版本中的重要变化,希望能给大家带去帮助。
注:Go每六个月发布一次。每个发布周期都分为持续约4个月的开发阶段,然后是为期3个月的测试和完善阶段(称为发布冻结期)。当前的发布周期预计于每年一月中旬和七月中旬开始,如下图所示。以Go 1.24为例,2024年7月开始plan,经过4个月开发,11月下旬冻结,再经历3个月的测试完善,预计2025年2月发布。
注:大家可以使用Go playground体验dev branch的最新特性,或在本地安装GoTip版本进行体验。2024年12月14日,Go 1.24RC1版本发布,大家也可以直接用go install golang.org/dl/go1.24rc1@latest体验,或到Go官方下载站unstable version中直接下载安装。
Go 1.18引入了泛型,Go 1.21版本新增了max、min和clear等预定义函数,而Go 1.23版本则引入了自定义迭代器。与这些创新相比,Go 1.24似乎又回归到了我们熟悉的“静默期”,没有显著的语法特性更新。
唯一一个值得提及的还是Go 1.23版本引入的实验特性:“带有类型参数的type alias”。如果你已经忘记这是一个什么语法特性,下面我就带你简单地回顾一下。
传统的类型别名的形式是这样的:
type P = Q
在《“类型名称”在Go语言规范中的演变》一文中我们介绍过,Q是Named Type,包括Predeclared Type、Anonymous Type、Existing Defined Type以及Existing Alias Type,甚至可以用泛型类型实例化后的类型作为Q,比如:
type MySlice[T any] []T
func main() {
type P = MySlice[int] // MySlice[int]作为Q
var p P
fmt.Println(len(p)) // 0
}
但P中不能包含类型参数!下面这样的类型别名定义是不合法的:
type P[T any] = []T
不过Go 1.23版本以实验特性(需显式使用GOEXPERIMENT=aliastypeparams)支持了带有类型参数的类型别名,在Go 1.24中,这个实验特性转正了,成为了默认特性。我们看看下面这个示例:
// go1.24-foresight/lang/generic_type_alias.go
package main
import "fmt"
type MySlice[T any] = []T
func main() {
// 使用int类型实例化MySlice
intSlice := MySlice[int]{1, 2, 3, 4, 5}
fmt.Println("Int Slice:", intSlice)
// 使用string类型实例化MySlice
stringSlice := MySlice[string]{"hello", "world"}
fmt.Println("String Slice:", stringSlice)
// 使用自定义类型实例化MySlice
type Person struct {
Name string
Age int
}
personSlice := MySlice[Person]{
{Name: "Alice", Age: 30},
{Name: "Bob", Age: 25},
}
fmt.Println("Person Slice:", personSlice)
}
使用Gotip直接运行上面示例,我们可以得到如下结果:
Int Slice: [1 2 3 4 5]
String Slice: [hello world]
Person Slice: [{Alice 30} {Bob 25}]
怎么理解带有类型参数的类型别名呢?在《Go 1.23中值得关注的几个变化》一文中,我们也介绍了Russ Cox给出的理解,即可以将其看成是一种“类型宏”(类似c中的#define):
type MySlice[T any] = []T
就是在任何出现MySlice[T]的地方,将其换成[]T。
在Go 1.23以实验特性出现的带类型参数的别名还有一些问题,比如下面这个本不该正常运行的示例(int切片类型是不满足comparable的),在Go 1.23.0版本中是可以正常编译运行的:
// go1.24-examples/lang/strict_alias.go
package main
import "fmt"
type MySlice[T any] = []T
type YourSlice[T comparable] = MySlice[T]
func main() {
// 使用int类型实例化MySlice
intSlice := MySlice[int]{1, 2, 3, 4, 5}
fmt.Println("Int Slice:", intSlice)
intsliceSlice := YourSlice[[]int]{
[]int{1, 2, 3},
[]int{4, 5, 6},
}
fmt.Println("IntSlice Slice:", intsliceSlice)
}
不过在Go 1.24中该问题被修正,如果你使用gotip运行该示例,你将得到类似下面编译错误:
./strict_alias.go:13:29: []int does not satisfy comparable
在gotip版go spec(截至2024.12.09)中,对带有类型参数的type alias有如下约束:
type A[P any] = P // illegal: P is a type parameter
即类型别名声明中的右侧已知类型不能是类型参数自身。但目前的gotip实现似乎忽略了这一条,下面代码在gotip下是可以正常编译运行的:
package main
import "fmt"
type A[P any] = P
func main() {
var a A[int] = 5 // identical to int
fmt.Println(a) // 5
}
此外Go 1.23.0中,带有类型参数的别名类型是不能跨包使用的,但Go 1.24中这条限制被取消了,带有类型参数的别名类型可以与常规类型别名一样跨包使用。
在Go 1.24中,你也可以通过设置GOEXPERIMENT=noaliastypeparams来禁用这一特性,但该设置将在Go 1.25中被移除。
Go 1.24版本在运行时方面实现了多个优化,包括采用基于Swiss Tables的原生map实现(#54766)、更高效的小对象内存分配以及改进的内部互斥锁实现,整体降低了2-3%的CPU开销。
Swiss Table是由Google工程师于2017年开发的一种高效哈希表实现,旨在优化内存使用和提升性能,解决Google内部代码库中广泛使用的std::unordered_map所面临的性能问题。目前,Swiss Table已被应用于多种编程语言,包括C++ Abseil库的flat_hash_map(可替换std::unordered_map)、Rust标准库Hashmap的默认实现等。在字节工程师的提案下,Go runtime团队决定替换原生map的底层实现,改为基于Swiss Table。通过基于gotip的实测,大多数测试项中,新版基于swiss table的map的性能都有大幅提升,有些甚至接近50%!之前写过一篇《Go map使用Swiss Table重新实现,性能最高提升近50%》,大家可以移步到那里了解关于基于Swiss Table实现的map的原理的详情,这里就不赘述了。
另外一个重要的性能优化是runtime: improve scaling of lock2中的提案,旨在针对当前runtime.lock2实现的问题进行优化,具体的propsal在design/68578-mutex-spinbit.md文件中。下面简略说一下该优化的背景、方案原理以及取得的效果。
当前runtime.lock2的实现通过三态设计(未锁定、锁定、锁定且有等待线程),在高竞争情况下,多个线程反复轮询mutex的状态字,产生大量缓存一致性流量。每个轮询线程需要从内存中加载状态字,并在更新时触发缓存行失效,这导致性能大幅下降。而每次释放锁时,无论是否已有线程在轮询mutex状态字,都会尝试唤醒一个线程,这进一步增加了系统负载。总之,现有的三态设计不能有效限制线程的忙等待行为。即使锁的临界区操作非常短,线程依然会因为抢占资源而竞争加剧。
新提案引入“spinbit”机制,扩展mutex状态字,增加一个”spinning”位,表示是否有线程处于忙等待状态。一个线程可以独占此位,在轮询状态字时拥有优先权。其他线程无需忙等待,直接进入休眠。同时提案优化了唤醒逻辑,当unlock2检测到已有线程正在忙等待时,不再唤醒休眠线程,从而减少不必要的线程切换和上下文切换。
目前该优化提供了基于futex和非futex系统调用的两个实现,基于futex的版本适用于Linux平台,通过精细控制休眠线程的列表,进一步减少竞争。
状态字中使用独立的位分别表示锁定状态、休眠线程存在与否、忙等待标志等,并通过位操作和Xchg8原子操作,确保性能和线程安全。
新方案在高竞争状况下取得了显著的可扩展性提升,新实现的spinbit机制能维持性能稳定,而不是像现有实现那样随线程数增加而急剧下降。基准测试表明,在GOMAXPROCS=20时,性能提升达3倍。大部分线程可以按设计预期那样,直接休眠而非忙等待,减少了电力消耗和处理器资源占用。同时,通过对休眠线程的显式管理,可实现有针对性的唤醒,降低线程长期休眠的风险(避免饿死)。
上述的基于Swiss table的map实现以及lock2优化是实验特性,但都是默认生效的,在Go 1.24中,你可以在构建阶段,通过显式设置GOEXPERIMENT=noswissmap和GOEXPERIMENT=nospinbitmutex关闭这两个实验特性。
如果你决定不碰cgo,那么你大可略过这节的说明。
传统cgo机制下调用c函数时,Go会保证传递给C函数的go指针指向的对象位于堆上。但如果C函数不保留Go指针的副本,并且不将该指针传递回Go代码,那么这个保证就是没有必要的。Go 1.24增加了下面注解用于显式告诉go编译器:不会有指针通过特定的C函数逃逸。
// #cgo noescape cFunctionName
此外,当Go函数调用C函数时,它默认会为C函数中再调用Go函数做好准备,这当然会有一些额外开销。这对于那些不会调回Go函数的C函数也是没有必要的。在Go 1.24中新增的#cgo nocallback注解就是用于告诉编译器这些准备工作不是必需的:
// #cgo nocallback cFunctionName
更多关于上述cgo优化c代码调用的新机制的说明,请参见cgo增加#cgo noescape和#cgo nocallback注解(#56378)。
Go 1.24之前,Go编译器允许在C类型的别名上声明方法,虽然某些时候它可以正常工作,如下面示例:
package main
/*
typedef int foo;
*/
import "C"
type foo = C.foo
func (foo) method() int { return 123 }
func main() {
var x foo
println(x.method()) // "123"
}
但这可能引入了潜在的类型安全性以及运行时错误问题,尽管目前为C类型别名添加方法的情形非常少。
Go 1.24通过引入了一个新的编译器检查修复了该问题,该检查利用了isCgoGeneratedFile函数和类型名称的特征(如_Ctype_前缀)来识别C类型别名,并禁止在C类型别名上声明方法。
本文对即将发布的Go 1.24版本的新特性进行了全面的展望。主要内容包括:
语法更新:Go 1.24未显著增加新语法特性,但实验性特性“带有类型参数的类型别名”已转正为默认特性,允许更灵活的类型别名定义。
编译器与运行时优化:
Go 1.24版本在语法上保持稳定,但在性能和安全性方面进行了多项关键优化,旨在提升开发者的体验和代码的效率。
在接下来的“Go 1.24新特性前瞻:工具链和标准库”一文中,我将继续为大家带来更丰富详尽的Go 1.24新特性,敬请期待!
本文涉及的源码可以在这里下载。
Gopher部落知识星球在2024年将继续致力于打造一个高品质的Go语言学习和交流平台。我们将继续提供优质的Go技术文章首发和阅读体验。同时,我们也会加强代码质量和最佳实践的分享,包括如何编写简洁、可读、可测试的Go代码。此外,我们还会加强星友之间的交流和互动。欢迎大家踊跃提问,分享心得,讨论技术。我会在第一时间进行解答和交流。我衷心希望Gopher部落可以成为大家学习、进步、交流的港湾。让我相聚在Gopher部落,享受coding的快乐! 欢迎大家踊跃加入!
著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。
Gopher Daily(Gopher每日新闻) – https://gopherdaily.tonybai.com
我的联系方式:
商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。
© 2024, bigwhite. 版权所有.
2024-12-14 09:11:40
本文永久链接 – https://tonybai.com/2024/12/14/webrtc-first-lesson-how-connection-estabish
在上一篇文章《WebRTC第一课:网络架构与NAT工作原理》中,我们介绍了WebRTC的网络架构和NAT的基本概念,学习了WebRTC采用端对端(P2P)的通信模型,知道了NAT(网络地址转换)的概念以及给像WebRTC这样的直接P2P通信带来的挑战。
在实际的网络环境中,建立WebRTC这样的端到端连接的确并非易事。因此,在这篇文章中,我将继续上一篇文章的内容,全面探讨一下WebRTC连接建立的全流程,涵盖信令交换、ICE候选信息采集和选择、NAT穿透的各个关键步骤,希望能给大家理解WebRTC技术栈带去帮助。
在深入细节之前,我们先用一个时序图来概览WebRTC连接建立的主要步骤:
注:上图由mermaid生成,对应的脚本在webrtc-first-lesson/part2/process-overview.mermaid。
这个过程可以概括为以下几个主要步骤:
在这个过程中,我们会涉及到几个关键概念:
接下来,我们将详细探讨这些概念,并基于这些概念详细说明WebRTC建连的全流程。
我们先来看看信令(Signaling)。
WebRTC技术栈中唯一没有标准化的就是信令(Signaling),但信令却又是WebRTC连接的基础和必不可少的部分。
信令是WebRTC中用于协调通信过程的“指令”,它负责在对等端之间交换建立连接所需的元数据,但不会直接传输音视频数据。信令的主要作用包括:
WebRTC本身并未定义信令协议标准,这主要考虑的是信令的设计与实现依赖于具体应用的需求,同时也有兼容性方面的考虑,比如:使WebRTC能够与现有的通信系统集成。在安全性方面,自定义信令也可以允许应用层来控制如何交换敏感信息。
目前用于WebRTC信令协议实现的常见方案主要包括基于WebSocket的自定义协议、SIP协议(Session Initiation Protocol)以及一些像XMPP(eXtensible Messaging and Presence Protocol)这样的成熟的即时通讯协议等。
就我个人而言,SIP和XMPP这样的传统协议都太重了,协议自身理解起来就有门槛!基于WebSocket的自定义协议,既简单又灵活,适合大多数业务不那么复杂的场景,在本文中,我们的信令协议就基于WebSocket自定义协议来实现。
Why Websocket?用WebSocket承载自定义信令协议的主要原因是几乎所有现代浏览器和后端框架都支持WebSocket,并且它是全双工通信,允许服务器和客户端随时发送消息,并且建立连接后,消息交换的开销也很小。
在设计信令语义时,我们通常会采用“Room”这个抽象模型来管理参与通信的客户端,这与WebRTC常用于互联网音视频应用不无关系。Room模型有助于组织和管理多个参与者,控制消息的广播范围,并可以实现更复杂的通信场景(如多人会议系统)。
下面是基于Room模型设计的信令交互的典型流程:
注:上图由mermaid生成,对应的脚本在webrtc-first-lesson/part2/signaling-room-model-flow.mermaid。
下面对图中几个关键流程做一些简要说明:
Client1向SignalingServer发送创建房间的请求,SignalingServer创建房间并返回房间ID给Client1。
Client2使用房间ID向SignalingServer发送加入房间的请求,SignalingServer通知Client1有新客户端加入。SignalingServer向Client2确认加入成功,并返回房间信息。重复相同的过程,Client3也如此加入了房间。
Client1创建Offer并通过SignalingServer向Client2发送Offer,SignalingServer将Offer转发给Client2。Client2创建Answer,并通过SignalingServer向Client1转发Answer。之后,Client1和Client2还会以类似的方式互相交换ICE候选信息,通过SignalingServer进行转发。
注:offer是由发起者(通常是调用方)创建的SDP(Session Description Protocol)消息,表示希望建立的媒体会话的描述。answer是由接收者(通常是被叫方)回复的SDP消息,表示其对offer的响应。SDP中通常包含媒体格式、网络信息、编解码器等详细信息,供双方协商和确认,具体可参考我之前的文章《使用Go和WebRTC data channel实现端到端实时通信》。
Client2向SignalingServer发送离开房间的请求,SignalingServer通知房间内的其他客户端(Client1 和 Client3)有客户端离开。
Client1(假设是房主)向SignalingServer发送关闭房间的请求。SignalingServer通知剩余的客户端(Client3)房间已关闭。
我们看到:这个流程展示了Room模型在WebRTC信令过程中的典型应用:
由此来看,支持Room模型的信令服务器要支持房间创建、加入房间、转发Offer和Answer、离开房间、房间关闭等关键API。同时我们也能看出这种模型非常适合于实现多人音视频通话、在线教室、游戏大厅等应用场景,它提供了一种结构化的方式来管理复杂的多方实时通信。
有了信令服务器,WebRTC通信两端就可以交换元信息了,这其中就包含用于建立端到端通信的ICE候选信息。接下来,我们就来看看WebRTC端到端建连的关键流程:交互式连接建立(ICE, Interactive Connectivity Establishment),以及这个过程中可能发生的NAT穿透。
ICE是一种用于在NAT(网络地址转换)环境中建立对等连接的协议,它允许两个agent(在RFC8445中用AgentL和AgentR指代,如下图)发现彼此的最佳通信路径,进而完成端到端的连接。
在这个过程中,我们还会涉及两个概念,一个是STUN(Session Traversal Utilities for NAT)服务器,一个是ICE Candidiate。
STUN服务器是帮助上述agent(AgentL和AgentR)发现其公网IP地址和端口的服务网元,这对于NAT穿透至关重要。而ICE Candidiate则是agent采集并与对端交换的、可能用于通信的潜在端点地址(IP地址和端口的组合)。
为了更直观的理解,下面我们来看一下通过ICE选择最佳通信路径的一般流程:
注:上图由mermaid生成,对应的脚本在webrtc-first-lesson/part2/ice-protocol-sequence.mermaid。
在信令流程发起和转发Offer/Answer之后,两个端都会开启ICE最佳通信路径选择的流程。
这第一步就是ICE Candidate Gathering,即收集ICE候选者(端点)信息。
在这个过程中,每个agent收集可能的候选者类型包括如下几种:
主机候选者,即本地接口的地址。通过直接使用本地网络接口的IP地址和端口即可获得,比如:192.168.1.2:5000。
反射候选者是通过STUN服务器查询公网IP地址和端口获得的,比如203.0.113.1:6000。
STUN通常是位于公网的一个服务器,比如最知名的公共stun是Google的“stun:stun.l.google.com:19302”。在收集反射候选者时,Agent(客户端)会向STUN服务器发送Binding Request(绑定请求),STUN服务器会响应一个Binding Response(绑定响应),其中包含客户端的公共IP地址和端口信息。
中继候选者是通过TURN服务器(Traversal Using Relays around NAT)获得的端点地址(在上图中未显示),是在开启中继模式的情况下,由客户端向TURN服务器发送请求以获取中继地址。只有在WebRTC通信双方(AgentL和AgentR)无法直连的情况下(通常是NAT穿透失败导致的),才会使用中继候选者,并通过TURN服务器进行数据中继来实现两端的数据通信。
注:在本文中,我们暂不考虑中继模式。
严格来说,对端反射候选者并非是在这个环节能获取到的候选者。对端反射是在ICE连接检查过程中动态发现的候选者,只有在连接检查过程中才能发现,且不太可预测,取决于网络拓扑和NAT行为。对比反射候选者,反射后选者是通过STUN服务器发现的。当一个端点向STUN服务器发送请求时,STUN服务器会回复该端点在公网上的IP地址和端口。而对端反射候选者是在两个端点尝试直接通信时发现的。当一个端点通过其已知的候选者(如主机候选者或反射候选者)向另一个端点发送数据时,如果成功到达,接收端会发现一个新的、之前未知的远程地址。这个新发现的地址就成为了对端反射候选者。
在ICE候选者信息收集的过程中,两端的Agent还要通过定期发送的STUN Binding请求,确保收集到的ICE反射/中继候选者信息在连接建立期间保持有效。这个过程在RFC 8445中被称为“Keeping Candidates Alive”,它可以帮助检测网络环境的变化,比如IP地址或端口的变化。通过定期的STUN请求,ICE可以确保候选者在NAT设备中的映射保持活跃,避免因长时间没有通信而被关闭。
两端的Agent在收集完候选者信息后,会通过信令服务器交换他们收集到的候选者信息,这个流程在前面的信令交互流程图中也是有的,是信令协议要支持的功能的一部分。
一旦ICE Candidate Gathering以及candidate交换结束,两端的agent会对自己收集到的candidate以及收到的对端的candidate信息进行”Candidate Priorization”,即对自己收集到候选者集合和交换得到的对端候选者集合分别按优先级进行排序。
RFC8445中给出推荐的候选者的优先级公式如下:
priority = (2^24)*(type preference) +
(2^8)*(local preference) +
(2^0)*(256 - component ID)
在公式中有三个名词:type preference、local preference和component ID。下面分别介绍一下这三个名词的含义:
Type preference一个表示候选者类型优先级的值。不同类型的候选者会被赋予不同的type preference值,以反映它们在ICE过程中的相对重要性。
它的取值范围通常是0到126之间的整数,值越大,优先级越高。 RFC8445中常见候选者类型及其推荐值如下:
主机候选者 (Host candidates): 126
反射候选者 (Server Reflexive candidates): 100
对端反射候选者 (Peer Reflexive candidates): 110
中继候选者 (Relay candidates): 0
通过不同类型的候选者的推荐值,我们也能看出:主机候选者 > 对端反射候选者 > 反射候选者 > 中继候选者。
注:Peer Reflexive candidates被赋予比Server Reflexive candidates更高的优先级,前面提过,是因为它不是在ICE候选者收集阶段就能发现的,而是在后面的连接检查阶段才能发现,因此可能代表更直接的连接路径。
local preference是一个表示本地优先级的数值,其取值范围0~65535。这个值由本地ICE agent根据自己的策略来设置。
RFC 8421(Guidelines for Multihomed and IPv4/IPv6 Dual-Stack Interactive Connectivity Establishment (ICE))进一步补充了关于local preference的使用建议,特别是在多宿主和双栈(IPv4/IPv6)环境下。建议为IPv6候选地址分配比IPv4更高的local preference值,比如:IPv6地址可以分配65535(最高优先级),IPv4地址可以分配65535-1=65534(次高优先级)。在多宿主(Multihomed)环境中,可以根据网络接口的特性(如带宽、延迟、成本等)来分配不同的local preference值。
注:多宿主环境(Multihomed)指的是一个设备或系统通过多个网络接口连接到网络的情况。这些接口可以连接到同一个网络,也可以连接到不同的网络。多宿主环境下,每个网络接口都可能产生多个ICE候选者,需要为不同接口的候选者分配合适的优先级,可能需要考虑不同网络接口的特性(如带宽、延迟、成本)。
Component ID用于区分同一媒体流中的不同组件。在ICE中,一个媒体流可能包含多个组件,例如RTP和RTCP。Component ID通常是从1开始的连续整数。在公式中使用(256 – component ID)是为了确保值较小的component ID得到较高的优先级。RTP组件通常被赋值为1,RTCP组件(如果存在)通常被赋值为2。Component ID在优先级计算中的作用相对较小,主要用于在其他因素相同的情况下,为同一流的不同组件提供细微的优先级区分。
下面我们用一个示例来演示一下候选者计算优先级的过程。示例将展示一个ICE agent如何计算自己的candidates和对端candidates的优先级。我们假设这是一个音频流的情况,涉及RTP组件。假设我们有两个agent:AgentL和AgentR,我们将关注AgentL的视角。
AgentL的收集的候选者集合如下:
Host candidate (IPv4): 192.168.1.10:50000
Server Reflexive candidate: 203.0.113.5:50000
Relay candidate: 198.51.100.1:50000
AgentL通过信令交换获得的AgentR的候选者集合如下:
Host candidate (IPv6): 2001:db8::1:5000
Host candidate (IPv4): 192.168.2.20:5000
Server Reflexive candidate: 203.0.113.10:5000
上面优先级计算公式中各个参数值选择如下:
Type preferences:
- Host: 126
- Server Reflexive: 100
- Relay: 0
Local preferences:
- IPv6: 65535
- IPv4: 65534
Component ID: 1 (RTP)
下面是AgentL的候选者优先级的计算过程:
- Host (IPv4): (2^24) * 126 + (2^8) * 65534 + (256 - 1) = 658871
- Server Reflexive: (2^24) * 100 + (2^8) * 65534 + (256 - 1) = 658195
- Relay: (2^24) * 0 + (2^8) * 65534 + (256 - 1) = 655595
AgentR的候选者优先级(从AgentL的角度计算)计算过程:
- Host (IPv6): (2^24) * 126 + (2^8) * 65535 + (256 - 1) = 658881
- Host (IPv4): (2^24) * 126 + (2^8) * 65534 + (256 - 1) = 658871
- Server Reflexive: (2^24) * 100 + (2^8) * 65534 + (256 - 1) = 658195
最终优先级排序(从高到低):
AgentR: Host (IPv6) - 658881
AgentR: Host (IPv4) - 658871
AgentL: Host (IPv4) - 658871
AgentR: Server Reflexive - 658195
AgentL: Server Reflexive - 658195
AgentL: Relay - 655595
这个优先级排序将用于指导下一阶段的ICE连接检查(ICE Connectivity Checks)顺序,但最终的连接选择还会考虑连接检查的结果。在实际场景中,可能会有更多的候选者,包括不同网络接口的多个Host候选者等等。
有了两端的候选者集合以及优先级值后,两个Agent就可以进入下一阶段ICE Connectivity Checks(连接检查)了。
连接检查实际也可以划分为三个阶段,我们逐一来看一下。
在 WebRTC 的 ICE(Interactive Connectivity Establishment)连接过程中,角色的确定对于连接检查非常重要。ICE 的角色分为两种:控制方(Controlling)和被控方(Controlled)。这些角色用于决定在多个候选路径中选择哪一条作为最终的连接路径。控制方(Controlling Agent) 负责最终选择使用哪个候选对进行通信,而受控方(Controlled Agent)则需遵循控制方的决定。
在offer/answer的信令模型中,通常发起offer的一方会被指定为控制方,而应答(answer)的一方会成为受控方。有时可能会出现两个agents都认为自己是控制方的情况。ICE提供了解决这种冲突的机制:每个agent生成一个随机数(称为tie-breaker),当发现冲突时,比较tie-breaker,tie-breaker较大的agent成为控制方。
ICE(Interactive Connectivity Establishment)连接检查是由控制方和被控方的两个ICE agent同时进行的。两者会各自发起连接检查,以确保双方能够建立有效的连接。控制方通过在检查中包含USE-CANDIDATE属性来提名(Nomination)候选对。
在某些情况下,角色可能会在ICE过程中切换,比如如果发现角色冲突并解决冲突后,又比如在ICE重启(restart)的特定场景下。
通信的双方,无论是控制端还是被控端都会独立形成自己的检查列表。
检查列表是所有可能的候选者对(Candidate Pair)的组合。让我们结合上面的示例,详细说明这个过程。
在我们的例子中,以AgentL为例,每个本地候选与每个远程候选会形成一对,这里会形成9个候选者对:
(L-Host, R-Host-IPv6)
(L-Host, R-Host-IPv4)
(L-Host, R-Server-Reflexive)
(L-Server-Reflexive, R-Host-IPv6)
(L-Server-Reflexive, R-Host-IPv4)
(L-Server-Reflexive, R-Server-Reflexive)
(L-Relay, R-Host-IPv6)
(L-Relay, R-Host-IPv4)
(L-Relay, R-Server-Reflexive)
根据之前计算的优先级,对候选对对优先级进行计算,并按从高到底进行排序。RFC8445中给出了候选者对优先级计算的公式:
// Let G be the priority for the candidate provided by the controlling agent.
// Let D be the priority for the candidate provided by the controlled agent.
pair priority = 2^32*MIN(G,D) + 2*MAX(G,D) + (G>D?1:0)
具体的计算过程这里就不体现了,排序后的检查列表可能如下:
(L-Host, R-Host-IPv6)
(L-Host, R-Host-IPv4)
(L-Server-Reflexive, R-Host-IPv6)
(L-Server-Reflexive, R-Host-IPv4)
(L-Host, R-Server-Reflexive)
(L-Server-Reflexive, R-Server-Reflexive)
(L-Relay, R-Host-IPv6)
(L-Relay, R-Host-IPv4)
(L-Relay, R-Server-Reflexive)
AgentR(被控方) 也会形成自己的检查列表,与AgentL类似,但AgentR并不主动选择最终的路径。
有了排序后的候选者对后,我们接下来便可以执行连接检查了,AgentL和AgentR会各自执行自己的检查。
我们以AgentL为例,看看执行连接检查的主要步骤。
AgentL开始按照检查列表的顺序(优先级由高到低)发送STUN Binding请求:
同时,AgentR也会执行类似的过程,按照他自己的检查列表发送自己的STUN Binding请求。
当AgentL收到STUN Binding响应时,可能有以下几种可能:
之后,AgentL便会更新候选列表,将新候选与所有远程候选配对,形成新的候选对,并根据ICE优先级算法重新排序检查和更新检查列表。AgentL还可能对新形成的候选对立即开始连接性检查。
如果两端都在NAT后面,那么Peer Reflexive候选者就是NAT穿透的关键!我们结合下图详细说说ICE过程是如何一步步的选出由新发现的Peer Reflexive组成的最终候选路径的。
注:上图由mermaid生成,对应的脚本在webrtc-first-lesson/part2/ice-nat-traversal-sequence.mermaid
图中使用A、B作为两个端点,通过stun服务器获取的反射候选为A’和B’,通过连接检查阶段发现的对端反射候选(Peer Reflexive)分别为A”和B”。接下来,我们详细说明一下图中流程。
端点A和B都收集主机候选。A和B都通过各自的NAT向STUN服务器发送请求,获取服务器反射候选(A’和B’)。
A和B交换各自的候选列表,包括主机候选和反射候选(A’和B’)。
A向B的反射候选B’发送STUN绑定请求,这个请求经过A的NAT和B的NAT。
B收到请求后,发现源地址(A”)与A提供的候选(A’)不匹配,因此创建一个新的Peer Reflexive候选A”。
B通过NAT链回复STUN绑定响应。
A收到响应后,从响应中的XOR-MAPPED-ADDRESS字段获知并创建自己的Peer Reflexive候选A”。
注:STUN协议中的XOR-MAPPED-ADDRESS字段可用于帮助对等方(peer)在ICE连接检查阶段找到自己的Peer Reflexive地址。
类似的过程在反向发生。A创建B的Peer Reflexive候选B”。B从A的响应中获知并创建自己的Peer Reflexive候选B”。
A和B都更新各自的检查列表,包括新的(A”, B”)对。
最终,(A”, B”)被选为最佳路径,实现双向NAT穿透。
一旦选定了最佳候选者对,ICE过程就结束了,可以开始实际的数据传输。
最后我们简单说说ICE重启(restart)。ICE restart提供了一种在不中断现有应用会话的情况下重新建立和优化网络连接的机制。这通常是因为网络条件发生了变化或者需要切换到更优的连接路径。下面序列图展示了ICE重启的基本流程:
注:上图由mermaid生成,对应的脚本在webrtc-first-lesson/part2/ice-restart-sequence.mermaid
ICE restart可能由多种原因触发,如网络变化、切换到更优路径、或解决连接问题。任何一方都可以发起ICE restart。
和前面的ICE流程不同之处在于重启时,发起restart的一方会生成新的ICE ufrag和password。这些新的凭证用于区分新的ICE会话和旧的会话。
之后的流程就和正常的ICE交互选出最优通信路径没有太大区别了!这里也就不重复说明了。
注:ICE restart不一定会改变控制方(controlling)和受控方(controlled)的角色。通常情况下,原有的角色分配会被保持。
在这篇文章中,我们深入探讨了WebRTC连接建立的全流程,涵盖了以下关键概念:
在实际生产应用中,我们可能还需要考虑以下几点:
在接下来的系列文章中,我将用一个相对完整的演示示例来展示WebRTC应用端到端建连的所有细节(通过TRACE级别日志),希望通过这些细节的分析能帮助大家更好地理解WebRTC的建连过程。
本文涉及的Mermaid源码在这里可以下载到 – https://github.com/bigwhite/experiments/blob/master/webrtc-first-lesson/part2
Gopher部落知识星球在2024年将继续致力于打造一个高品质的Go语言学习和交流平台。我们将继续提供优质的Go技术文章首发和阅读体验。同时,我们也会加强代码质量和最佳实践的分享,包括如何编写简洁、可读、可测试的Go代码。此外,我们还会加强星友之间的交流和互动。欢迎大家踊跃提问,分享心得,讨论技术。我会在第一时间进行解答和交流。我衷心希望Gopher部落可以成为大家学习、进步、交流的港湾。让我相聚在Gopher部落,享受coding的快乐! 欢迎大家踊跃加入!
著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。
Gopher Daily(Gopher每日新闻) – https://gopherdaily.tonybai.com
我的联系方式:
商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。
© 2024, bigwhite. 版权所有.
2024-12-11 07:56:49
本文永久链接 – 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)有多大吗:
这个庞然大物的算力可能还不如现在你手腕上带的智能手表。随着物理学、材料科学等前沿学科的突破,“豪华大吊灯”变成一块板卡也不是没有可能。
与经典计算机的融合意味着如今的开发人员依然可以使用熟悉的人机交互界面与量子计算机打交道,包括量子计算的编程。但要针对量子计算机编程,需要了解量子计算的一般原理,就像基于英伟达的CUDA进行GPU编程一样。
然而,目前的现实是量子计算机依然是“昂贵且稀缺的设备”,世界范围内只有巨头公司以及大型科研院所才拥有真实的量子计算机。但普通程序员仍然可以通过模拟器工具一窥其奥秘,理解其核心概念并为未来应用做好准备:
这些量子模拟器可以在经典计算机上模拟量子计算过程,让量子计算的学习和实验变得触手可及。在这篇文章中,我就和大家一起学习一下量子编程的基本概念和编程方法,并使用模拟器编写一些简单的量子计算程序。
要理解量子计算,我们需要先回顾经典计算的基本概念和抽象,然后建立起通向量子计算的认知桥梁。
在经典计算中,信息的基本单位是比特(bit),其值由两种电平状态来表示:
即每个比特的值只能取0或1。这种电平的二元状态形成了数字电路的基础,使得计算机能够处理和存储信息。
比特的电平状态不仅用于计算,还用于信息的存储和传输。在存储器(如RAM)中,每个比特的位置对应于一个电平状态,通常通过电容或电感来保持电平。在数据传输中,信号的电平变化用于表示比特的流动,例如在串行或并行通信中。
经典计算机使用比特进行所有信息处理。而布尔逻辑正是基于比特的逻辑运算,包括以下基本操作:
这些基本逻辑门是构建更复杂运算的基础,所有复杂的计算都可以通过这些简单的逻辑操作组合而成。
门电路模型是经典计算的核心,利用逻辑门的连接来实现复杂的计算任务。电路由逻辑门(如与门、或门、非门等)构成,通过这些门的组合与连接,可以构建出加法器、乘法器等基本算术单元,以及更复杂的功能,比如处理器中的运算单元。门电路模型的优势在于其可组合性和可扩展性,使得计算机能够执行从简单到复杂的各种任务。
编程语言为程序员提供了更高级的抽象,使得比特操作不再需要直接进行,尤其是近半个世纪以来诞生的高级语言,如C/C++、Java、Go、Python等。通过这些高级编程语言,开发者可以使用更接近自然语言的语法来编写代码,底层的比特操作则由编译器或解释器自动处理。这种抽象不仅提高了编程效率,也使得程序员可以专注于算法和逻辑,而不必深入底层硬件细节。例如,C、Go、Python等编程语言提供了丰富的类型系统、控制结构与高级数据结构,使得程序员可以用简单的语句来处理复杂的数据操作。随着技术的发展,面向对象编程、函数式编程等新范式也相继出现,为软件开发提供了更灵活的方式。
以上对经典计算机的认知路径能够为理解量子计算机提供重要的基础和视角。接下来,我们就沿着这个路径认识一下量子计算的核心概念。
提及“量子”,人们首先想到的可能是大学物理中的量子力学。量子的概念来源于物理学,但和电子等真实存在的粒子不同,“量子”并不是指某个特定的粒子,而是一个广泛的基本概念,用来描述物质和能量在微观尺度上的离散性。在物理学中,量子具有以下主要特性:
海森堡的不确定性原理。该原理指出,某些物理量的精确值不能同时被完全确定,比如位置和动量。即使在理想情况下,测量一个量的精确性会导致对另一个量的测量不确定性增加。
在量子力学中,物体的状态被称为量子态。量子态可以通过波函数来描述,波函数包含了物体可能的所有状态的信息。此外,量子叠加原理允许一个量子系统同时处于多个状态。例如,电子可以同时占据多个能级,直到被测量时才“坍缩”到某个具体状态。(关于叠加态,后面详说)
量子纠缠是指两个或多个量子系统之间存在一种特殊的关联,使得对其中一个系统的测量会立即影响到另一个系统的状态,无论它们的距离有多远。量子纠缠是量子计算和量子通信的重要基础。
注:不要问我如何深入理解上述的特性,如果你对量子机制感兴趣,可以去读读费曼教授的物理学讲义,如果你能读懂的话:)。
而量子计算就是建构在量子的上述特性之上的。
经典计算机中,信息和操作的基本单位是比特,在量子计算中,信息和操作的基本单位是量子比特(qubit)。不过与经典比特的确定的二元状态(0或1)不同,量子比特处于叠加态(Superposition)。
要理解量子计算,首当其冲的就是理解什么是量子比特的叠加态。
注意!注意!烧脑内容即将来袭!
学习大学物理时,估计大家都接触过量子力学的皮毛,可能让你印象最深的就是“薛定谔的猫(Schrödinger’s Cat)”!
这是奥地利著名物理学家薛定谔提出的一个思想实验,是指将一只猫关在装有少量镭和氰化物的密闭容器里。镭的衰变存在几率,如果镭发生衰变,会触发机关打碎装有氰化物的瓶子,猫就会死;如果镭不发生衰变,猫就存活。根据量子力学理论,由于放射性的镭处于衰变和没有衰变两种状态的叠加,猫就理应处于死猫和活猫的叠加状态。这只既死又活的猫就是所谓的“薛定谔的猫”。
我们的量子比特就好比那只“薛定谔的猫”,只不过它的状态不是“死”和“活”的叠加态,而是0和1的叠加态。要知道“薛定谔的猫”的最终状态,需要观察者。而要知道一个量子比特的最终状态,需要对其进行测量(measure)。
这里有两个概念需要深入理解,一个是0和1的叠加态,另一个则是测量。
经典计算机的比特的状态是确定性的,你设置为1,它就是1,你设置为0,它就是0。如果在其生命周期内,你不去修改它,它会一直保持其最初的状态。
但量子比特的状态却不是确定性的,而是概率性的,即量子比特是以概率的形式存在。不过要了解量子比特,我们需要先了解如何表示量子比特,就像我们在经典计算中用二进制数表示经典比特那样。
在量子计算领域,量子比特有两种表示法:狄拉克符号表示法(Dirac notation)和布洛克球(Bloch sphere)几何表示法,下面分别简单介绍一下。
狄拉克符号是一种用于表示量子态的数学符号,也称为“凯特尔符号(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
它们都满足量子比特叠加态的归一化条件。
和任何计算对象一样,量子比特也有其图形化的表示法,即布洛克球。接下来,我们就来说明一下什么是布洛克球。
布洛克球(如下图)是一种用于直观理解量子比特状态的表示法。
量子比特的状态可以用球面上的点表示,通常使用极坐标。
如上图所示:
角度θ表示从正Z轴的倾斜角度(0到π),是布洛赫球上的纬度角,决定状态向量在z轴上的投影,描述了比特更接近|0⟩还是|1⟩。
角度ϕ表示在XY平面上的方位角(0到2π),是布洛赫球上的经度角,表示相对相位。不同的ϕ可能不会影响单独测量|0⟩或|1⟩的概率,但会影响量子态之间的干涉效应,因此对量子计算非常重要。
布洛克球的每个点代表一个量子比特的可能状态,其状态可以表示为下面公式:
球的北极(|0⟩)和南极(|1⟩)分别对应量子比特的基态和激发态,而球面上的其他点则表示叠加态。
量子比特一直处于叠加态,其结果也是不确定的。但我们基于量子比特进行计算的目的是为了得到确定的结果,这就需要对量子比特进行测量。
测量是量子系统与经典系统之间的交互过程,通过这一过程,量子比特的叠加态将坍缩到一个确定的状态(基态0或激发态1)。
由前面的内容我们知道,量子比特的叠加状态是一种概率性的,在量子测量中,并没有一个固定的概率值来决定坍缩为基态或激发态,因此测量结果也是随机的。
测量结果为0的概率(基态)为P(0)=∣α∣^2,测量结果为1的概率(激发态)为:P(1)=∣β∣^2。
如果P(0) >= P(1),量子比特更有可能坍缩为|0⟩(经典比特值为0),反之,量子比特更有可能坍缩为|1⟩(经典比特值为1)。
测量将使得量子比特失去叠加状态,转变为经典状态。这意味着在测量之后量子比特的叠加态特性将不复存在了。
在经典计算中,单个经典比特只能表示两个状态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种状态的叠加,至于测量后会得到哪种状态,那就是随机的了,要看哪种状态的概率更大。
抽象出量子比特的目的是为了运算,经典比特的运算由各种逻辑门电路实现,并通过门电路的组合实现更为强大和复杂的运算能力。量子比特的操作也是由量子门电路实现的。量子计算中的门电路是对量子比特进行操作的基本单元,其作用是对量子比特的状态进行变换。下面我们就来看看都有哪些常见的量子门电路。
单量子比特门作用于一个量子比特,改变其状态。
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⟩
类似经典计算中的NOT门,可以实现|0⟩和|1⟩的交换,即将|0⟩变为|1⟩,|1⟩变为|0⟩:
X∣0⟩=∣1⟩
X∣1⟩=∣0⟩
Pauli-Y门不仅可以像Pauli-X门那样翻转比特的状态,还引入了一个相位因子:
Y∣0⟩=i∣1⟩
Y∣1⟩=−i∣0⟩
Pauli-Z门主要负责相位反转。它不会改变|0⟩状态,但会对|1⟩状态施加相位反转。它在量子信息处理中用于引入相位差:
Z∣0⟩=∣0⟩
Z∣1⟩=−∣1⟩
S门,也称为相位门,能够对量子比特状态施加特定的相位。它只影响|1⟩态的相位,在|1⟩态上施加相位π/2(即i),而不改变|0⟩态。
S∣0⟩=∣0⟩
S∣1⟩=i∣1⟩
T门,也称为四分之一相位门,类似于S门,但施加的相位为π/4,即|1⟩态上施加相位π/4,而对|0⟩态没有影响:
T∣0⟩=∣0⟩
T∣1⟩=e^(i*π/4)∣1⟩
S门和T门可以组合使用,形成更复杂的相位操作。例如,S门可以被看作是T门的平方。这些相位门在量子计算中具有重要作用,尤其是在量子态的干涉和量子算法(如量子傅里叶变换)中。
量子态的干涉是量子力学中的一个核心现象,类似于经典波动中的干涉现象。它描述了不同量子态之间相位关系的作用,导致量子态的概率幅相加或相消,从而影响测量结果。
干涉现象可以分为两种类型:
量子计算中常见的两种算法:Shor算法和Grover算法就是利用量子干涉来提高计算效率的。
双量子比特门是量子计算中用于处理两个量子比特之间关系的基本操作。这些门能够实现量子比特之间的纠缠(关于这个概念稍后再说)和相互作用,是量子计算的重要组成部分。下面介绍几种常见的双量子比特门:
CNOT门(Controlled-NOT Gate)是最常用的双量子比特门之一。它对一个量子比特(目标比特)施加NOT操作,前提是另一个量子比特(控制比特)处于|1⟩状态,具体表现如下(第一个量子比特为控制比特,第二个量子比特为目标比特):
- 输入状态|00⟩,变为 |00⟩
- 输入状态|01⟩,变为 |01⟩
- 输入状态|10⟩,变为 |11⟩
- 输入状态|11⟩,变为 |10⟩
CNOT门能够创建量子比特之间的纠缠,是量子计算中实现量子算法的基础。
CZ门是另一种重要的双量子比特门。它对目标量子比特施加Z门(相位反转)操作,前提是控制量子比特(第一个量子比特)的状态为|1⟩。其具体表现如下:
- 输入状态|00⟩, 变为|00⟩
- 输入状态|01⟩, 变为|01⟩
- 输入状态|10⟩, 变为|10⟩
- 输入状态|11⟩, 变为-|11⟩(施加相位反转)
CZ门用于引入相位关系,也常用于量子纠缠和量子算法中。
SWAP门是一种双量子比特门,它的主要功能是交换两个量子比特的状态。具体来说,如果有两个量子比特A和B,SWAP门会将它们的状态互换。
给定输入状态|AB⟩,SWAP门的作用如下:
|00⟩ → |00⟩
|01⟩ → |10⟩
|10⟩ → |01⟩
|11⟩ → |11⟩
我们看到:SWAP门将|0⟩和|1⟩的状态互换,而|00⟩和|11⟩保持不变。
在量子通信中,SWAP门可以用于在不同的量子比特之间交换信息。在量子电路中,SWAP门可以用来重排量子比特的位置,以实现特定的逻辑操作。
接下来,我们再来看看常用的多量子比特门,即三个或三个以上量子比特的门电路。
Toffoli门是一个三量子比特门,只有在前两个量子比特均为|1⟩时,才对第三个量子比特施加NOT操作。其具体行为表现如下:
- 输入状态|000⟩, 变为|000⟩
- 输入状态|001⟩, 变为|001⟩
- 输入状态|010⟩, 变为|010⟩
- 输入状态|011⟩, 变为|011⟩
- 输入状态|100⟩, 变为|100⟩
- 输入状态|101⟩, 变为|101⟩
- 输入状态|110⟩, 变为|111⟩
- 输入状态|111⟩, 变为|110⟩
Toffoli门也是经典计算的量子对应物,能够实现复杂的逻辑操作,常用于量子纠错和量子算法中。
CSWAP门是一种受控的操作双量子的比特门,但因为有一个额外的控制量子比特,因此将其纳入多量子比特门一类。和SWAP门无条件交换两个量子比特状态不同,CSWAP门只有在控制量子比特处于|1⟩状态时,才会交换目标量子比特的状态。它可以看作是SWAP门的受控版本。
给定输入状态|CAB⟩,其中C为控制比特,A和B为目标比特,CSWAP门的作用如下:
当C = |0⟩时,|CAB⟩保持不变。
当C = |1⟩时:
|101⟩ → |110⟩
|100⟩ → |101⟩
|011⟩ → |011⟩
|010⟩ → |010⟩
和经典计算的门电路组合一样,通过对上面量子门的组合,我们可以得到一些非常实用的门电路,其中贝尔态门(Bell State Gate)就是一种非常常用的量子门,它的主要功能是将两个量子比特从一个未纠缠的状态转换为一个纠缠状态。常见的实现方式是通过组合Hadamard门和CNOT门来生成贝尔态。贝尔态在量子通信、量子密码学和量子纠缠研究中都有着重要应用。
假设初始态是两个量子比特的分离态∣ψ⟩=∣00⟩,以下步骤可以生成Bell态:
H∣0⟩= 1/√2(∣0⟩+∣1⟩)
这之后整体状态变为:
|ψ⟩= 1/√2(∣0⟩+∣1⟩)∣0⟩ = 1/√2(∣00⟩+∣10⟩)
如果控制比特是∣0⟩,目标比特保持不变。
如果控制比特是∣1⟩,目标比特翻转。
CNOT门作用后,状态变为:
|ψ⟩= 1/√2(∣00⟩+∣11⟩)
这就是Bell态!
好,到这里也该说一说之前提到的量子纠缠态了。
一提到量子纠缠,人们通常会想到它的神秘性和非经典特性,尤其是关于量子之间的瞬时关联和信息传递的潜能。这种现象挑战了经典物理的直觉,常常引发人们对量子计算、量子通信和量子隐形传态等前沿技术的兴趣与讨论。同时,量子纠缠也引发了关于量子力学基础的哲学思考,例如关于现实、因果关系和信息本质的深层次问题。
通过前面的学习,我们知道每个量子比特都有自己的状态,可以是初始基态∣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公里的距离上分发了纠缠态光子对,这是量子通信中“量子纠缠分发”的成功案例。
到这里,我们已经介绍了量子计算的常见各种量子比特门电路,而基于上述量子比特门电路来解决经典计算难以高效解决的问题的算法,就被称为量子算法。下面我们再简单介绍一下量子算法的特性以及有哪些常见的量子算法。
由于量子比特的“超出经典直觉”的性质,相对于经典计算中的算法,量子算法的理解路径更为“崎岖”,有时候大脑需要一些“天马行空”。
我们以量子计算的经典入门示例算法:多伊奇-乔萨算法为起点,来看一下量子计算算法的一般特点和设计模式。
多伊奇-乔萨算法(Deutsch–Jozsa algorithm)是戴维·多伊奇和理查德·乔萨于1992年提出的一种确定性量子算法,该算法展示了量子算法在特定问题上指数级加速的能力,以及量子算法设计的一般模式。下面我们就来介绍一下该算法。
《量子计算和量子信息》一书在介绍这个算法时,使用了一个Alice和Bob的故事来解释,这里我们也借鉴一下,希望能更好的帮助大家理解其核心概念和量子计算的优势。
话说Alice和Bob是一对喜欢挑战逻辑问题的好朋友。某一天,Alice设计了一个“神秘黑箱”(即函数f(x)),可以接收n位二进制输入(例如x = 000, 101, 111),并输出一个二进制结果(0或1)。
但是,Alice做了一些特殊限制:
Alice给Bob的任务是:确定这个黑箱到底是常值还是平衡的。
Bob起初考虑使用经典计算机来完成这个任务。他每次输入一个值x,黑箱会返回f(x)。为了判断f(x)是“常值”还是“平衡”,他必须测试多个x:
但问题是:最坏情况下,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函数,可以将量子计算应用于各种实际问题,包括优化、搜索和密码分析等领域。
在这一阶段,我们对前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次调用。
这个算法也代表了量子算法的一般模式,大致都是这样的:
在上面Bob和Alice的故事中,我们提到了量子并行计算,这也是Bob只需要一次调用黑箱即可完成判断的原因,那到底什么是量子并行计算呢?我们继续往下看。
如果说理解叠加态,我们还有概率这个熟知的经典概念可以利用。在理解量子并行性上,我们的大脑只能“天马行空”了。
巧的是,量子并行性的概念也是由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认为:量子并行本质上是量子态相互作用产生的干涉图样:每个量子态都有助于形成集体干涉图样,类似于天线阵列中信号的组合。然而,与多个独立进程同时运行的经典并行不同,量子并行的特点是复杂的干扰网。减少量子并行性的概念强调了量子算法中相长干涉和相消干涉之间的微妙平衡。虽然相长干扰会放大某些计算路径,但相消干扰会选择性地抑制其他计算路径,最终引导系统找到正确的解决方案。并且,传统的量子应用算法通常会经历一个初始阶段,其中它们利用所有可用的并行性、叠加计算、操纵阶段并执行测量。在此初始阶段,量子算法最大化并行性。然而,由于干扰现象,随着应用的进展,并行性会减弱,从而降低了量子并行性。也就是说在任何量子算法中,从经典输入状态产生量子并行性的机制以及减少数据并行性以识别正确答案的机制都是必不可少的。
不管你是否理解了,或理解了多少,我们都要向下进行了。我们来看看如何用模拟器来实现上面的Deutsch–Jozsa algorithm。
对于量子计算初学者而言,量子编程语言和模拟器就像是通往量子世界的入口和学习工具。
目前一些大厂提供了专门的量子编程语言以及模拟器甚至量子计算硬件(或者虚拟机)来帮助开发者了解量子计算。主流的语言包括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循环对每个量子比特顺序处理了一遍来模拟量子并行。
本文探讨了量子计算的基本概念及其与经典计算的关系,包括回顾了经典计算机的基本原理,包括比特、布尔逻辑和门电路模型,并引入了量子计算的核心概念,如量子比特(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大模型的辅助。由于这是我第一次学习量子计算,加之该领域的内容相对抽象且复杂,因此笔记中可能存在一些错误,敬请谅解。
本文涉及的源码可以在这里下载。
Gopher部落知识星球在2024年将继续致力于打造一个高品质的Go语言学习和交流平台。我们将继续提供优质的Go技术文章首发和阅读体验。同时,我们也会加强代码质量和最佳实践的分享,包括如何编写简洁、可读、可测试的Go代码。此外,我们还会加强星友之间的交流和互动。欢迎大家踊跃提问,分享心得,讨论技术。我会在第一时间进行解答和交流。我衷心希望Gopher部落可以成为大家学习、进步、交流的港湾。让我相聚在Gopher部落,享受coding的快乐! 欢迎大家踊跃加入!
著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。
Gopher Daily(Gopher每日新闻) – https://gopherdaily.tonybai.com
我的联系方式:
商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。
© 2024, bigwhite. 版权所有.