runtime/trace包包含了一个强大的工具,可用于理解和调试Go程序。该功能允许我们在一段时间内对每个goroutine的执行进行跟踪。使用go tool trace命令(或优秀的开源工具gotraceui),我们就可以可视化和探索这些跟踪数据。
跟踪的魔力在于,它可以轻松揭示程序中一些通过其他方式很难发现的问题。例如,大量goroutine在同一个channel上阻塞导致的并发瓶颈,在CPU分析中可能很难发现,因为没有执行(execution
)需要采样。但在执行跟踪中,执行的缺失将被清晰地呈现,而阻塞goroutine的堆栈跟踪会快速指出问题所在。
Go开发者甚至可以在自己的程序中使用Task、Region和Log进行检测,从而将他们关注的高级问题与低级执行细节联系起来。
不幸的是,执行跟踪中丰富的信息往往难以获取。历史上,以下四个主要问题一直阻碍着跟踪的使用:
如果你在过去几年使用过跟踪,可能会因上述一个或多个问题而感到沮丧。但我们很高兴地分享,在最近两个Go版本中,我们在这四个领域都取得了重大进展。
在Go 1.21之前,对许多应用程序而言,跟踪的运行时开销约在10-20%的CPU范围内,这限制了跟踪只能被选择性使用,而不能像CPU profiling
那样持续使用。事实证明,跟踪的大部分成本归结于traceback(跟踪回溯,在计算机编程中,它通常指的是程序运行时发生错误或异常时,系统生成的调用栈信息,用于帮助开发者定位问题的源头。)。运行时产生的许多事件都附带了堆栈跟踪,这对于实际识别关键执行时刻goroutine的行为是非常有价值的。
感谢Felix Geisendörfer和Nick Ripley在优化traceback效率方面所做的工作,执行跟踪的运行时CPU开销已经显著降低,对许多应用程序而言,降至1-2%。你可以在Felix关于这个主题的精彩博文中阅读更多相关工作的细节。
跟踪格式及其事件的设计侧重于相对高效的发送,但需要工具来解析并维护整个跟踪的状态。分析数百MB的跟踪可能需要几GB的内存!
这个问题从根本上说是由于跟踪生成的方式造成的。为了保持运行时开销低,所有事件都被写入类似线程本地缓冲区。但这意味着事件出现的顺序与真实发生的顺序不一致,跟踪工具需要负担重任来弄清楚真正发生的情况。
在保持低开销的同时使跟踪可扩展的关键在于,偶尔分割正在生成的跟踪。每个分割点的行为有点像同时禁用和重新启用跟踪。到目前为止的所有跟踪数据代表一个完整且独立的跟踪,而新的跟踪数据将无缝地从中断处继续。
你可能已经想到,解决这个问题需要反思和重写运行时中跟踪实现的大量基础部分。我们很高兴地说,这项工作在Go 1.22中完成并现已正式推出。重写带来了许多良好的改进,包括对go tool trace命令的一些改进。如果你有兴趣,所有详细内容都在设计文档中。
(注:go tool trace仍会将整个跟踪加载到内存中,但对于由Go 1.22+程序生成的跟踪,移除此限制现已变为可行。)
假设你在开发一个web服务,一个RPC花费了非常长的时间。当你意识到RPC已经运行很久时,你无法在那时开始跟踪,因为导致请求变慢的根本原因已经发生并且没有被记录下来。
有一种称为飞行记录(flight recording
)的技术可以帮助解决这个问题,你可能已经在其他编程环境中接触过了。飞行记录的思路是持续开启跟踪,并一直保留最新的跟踪数据,以防万一。然后,一旦发生有趣的事情,程序就可以直接写出它所拥有的数据!
在可以分割跟踪之前,这基本上是行不通的。但由于低开销使得连续跟踪变得可行,而且现在运行时可以随时分割跟踪,因此实现飞行记录变得很直接。
因此,我们很高兴地宣布在golang.org/x/exp/trace包中提供了一个飞行记录器实验。
请尝试使用它!下面是一个设置飞行记录器来捕获长时间HTTP请求的示例,可以帮助你入门。
|
|
果您有任何反馈,无论是正面的还是负面的,请分享到提案问题中!
随着对跟踪实现的重写,我们也努力清理了其他的跟踪内部组件,比如go tool trace。这催生了一次尝试,目标是创建一个足够好的可共享跟踪读取器API,使跟踪更易于访问。
就像飞行记录器一样,我们很高兴地宣布,我们也有一个实验性的跟踪读取器API,希望能与大家分享。它与飞行记录器位于同一个包中,即golang.org/x/exp/trace。
我们认为它已经足够好,可以在此基础上构建东西了,所以请务必试用!下面是一个示例,它测量了由于等待网络而阻塞的goroutine阻塞事件的比例。
|
|
就像飞行记录器一样,有一个提案问题可以作为留下反馈的好地方!
我们想特别提及Dominik Honnef,他很早就试用了这个API,提供了宝贵的反馈,并为API贡献了对旧版本跟踪的支持。
这项工作能够完成,在很大程度上要感谢一年多前成立的诊断工作组的帮助,该小组由来自Go社区各界的利益相关者组成,并向公众开放。
我们要借此机会感谢在过去一年中定期参加诊断会议的社区成员:Felix Geisendörfer、Nick Ripley、Rhys Hiltner、Dominik Honnef、Bryan Boreham和thepudds。
你们所做的讨论、反馈和工作是我们走到今天这一步的关键推动力。再次感谢!
]]>推进Go的极限:从net.Dial到系统调用、AF_PACKET和极速AF_XDP。数据包发送性能的基准测试。
最近,我编写了一个Go程序,向数百万个IP地址发送ICMP ping消息。显然,我希望这个过程能尽可能快速高效地完成。因此,这促使我研究各种与网络栈交互和快速发送数据包的各种方法。这是一个有趣的旅程,所以在本文中,我将分享一些学习成果,并记录下来供将来参考:)你将看到,仅使用8个内核就可以达到1880万数据包/秒。这里还有一个GitHub仓库,其中包含了示例代码,可以方便地跟随学习。
让我们先简单介绍一下问题背景。我希望能够从Linux机器上每秒发送尽可能多的数据包。有一些使用场景,例如我之前提到的Ping示例,但也可能是更通用的东西,如dpdk-pktgen或者类似iperf的工具。我想你可以将其总结为一种数据包生成器。
我使用Go编程语言来探索各种选项。一般来说,所探索的方法可以应用于任何编程语言,因为这些大多是围绕Linux内核提供的功能而构建的Go特定接口。但是,您可能会受到您最喜欢的编程语言中存在的库或支持的限制。
让我们开始冒险,探索Go中生成网络数据包的各种方式。我将介绍各种选项,最后我们将进行基准测试,显示哪种方法最适合我们的使用场景。我在一个Go包中包含了各种方法的示例;你可以在这里找到代码。我们将使用相同的代码运行基准测试,看看各种方法相比如何。
net.Dial
方法是在Go中建立网络连接最先想到的选择。它是标准库net包提供的一种高级抽象方法,旨在以易于使用和直观的方式建立网络连接。您可以使用它进行双向通信,只需读写net.Conn
(套接字)而无需担心细节。
在我们的情况下,我们主要关注发送流量,使用的net.Dial
方法如下所示:
|
|
在此之后,您可以简单地像这样将字节写入conn:
|
|
您可以在文件af_inet.go中找到我们使用这种方法的代码。
就是这样!非常简单,对吗?然而,正如我们将在基准测试中看到的,这是最慢的方法,不是快速发送数据包的最佳选择。使用这种方法,我们可以达到大约697,277个数据包每秒。
深入到网络栈层面,我决定在Go中使用原始套接字来发送数据包。与更抽象的net.Dial
方法不同,原始套接字为我们提供了与网络栈更低层次的接口,可以对数据包头部和内容进行细粒度控制。这种方法允许我们手动构建整个数据包,包括IP头部。
要创建原始套接字,我们必须自己进行系统调用,给它正确的参数,并提供将要发送的流量类型。然后我们将获得一个文件描述符。接下来,我们可以对这个文件描述符进行读写操作。从高层次来看就是这样;完整代码请参见rawsocket.go:
|
|
就是这样,现在我们可以像这样对文件描述符进行原始数据包的读写操作:
|
|
由于我使用了IPPROTO_RAW
,我们绕过了内核网络栈的传输层,内核期望我们提供完整的IP数据包。我们使用BuildPacket函数来实现这一点。工作量略有增加,但原始套接字的好处在于你可以构造任何你想要的数据包。
我们告诉内核只需接收我们的数据包,它需要做的工作就少了,因此这个过程更快。我们真正要求网络栈做的就是接收这个IP数据包,添加以太网头部,然后将其交给网卡进行发送。因此,很自然地,这个选项确实比Net.Dial选项更快。使用这种方法,我们可以达到约793,781个数据包每秒,比net.Dial方法高出约10万数据包每秒。
现在我们已经熟悉了直接使用系统调用,我们还有另一个选择。在这个例子中,我们直接创建一个UDP套接字,如下所示:
|
|
在此之后,我们可以像之前一样使用Sendto
方法简单地将有效负载写入套接字。
|
|
它看起来类似于原始套接字示例,但存在一些差异。关键区别在于,在这种情况下,我们创建了UDP类型的套接字,这意味着我们不需要像之前那样构造完整的数据包(IP和UDP头部)。使用这种方法时,内核根据我们指定的目标IP和端口来构造UDP头部,并处理将其封装到IP数据包的过程。
在这种情况下,有效负载仅是UDP有效负载。实际上,这种方法类似于之前的Net.Dial方法,但抽象程度更低。
与之前的原始套接字方法相比,我现在看到的是861,372个数据包每秒 - 提高了7万多。我们在每一步都变得更快。我猜我们获得了内核中一些UDP优化的好处。
在这里看到使用Pcap来发送数据包可能会感到惊讶。大多数人都知道pcap是从诸如tcpdump或Wireshark这样的工具中捕获数据包的。但它也是一种相当常见的发送数据包的方式。事实上,如果您查看许多Go-packet或Python Scappy示例,这通常是列出的发送自定义数据包的方法。因此,我认为我应该包括它并查看其性能。我持怀疑态度,但当看到每秒数据包数时,我很高兴地感到惊讶!
首先,让我们来看看Go语言是怎么实现的;同样,完整的示例请查看我在pcap.go中的实现。
我们首先创建一个Pcap句柄,如下所示:
|
|
然后我们手动创建数据包,类似于前面的原始套接字方法,但在这种情况下,我们包含了以太网头部。之后,我们可以将数据包写入pcap句柄,就完成了!
|
|
令我惊讶的是,这种方法带来了相当大的性能提升。我们远远超过了每秒一百万个数据包的大关: 1,354,087个数据包每秒 - 几乎比之前高出50万个数据包每秒!
注意,在本文的后面,我们将看到一个警告,但值得注意的是,当发送多个流(Go 例程)时,这种方法的工作效果会变差。
在我们探索 Go 中网络数据包制作和传输的各个层次时,接下来发现了 AF_PACKET
方法。这种方法在 Linux 上的入侵检测系统中很受欢迎,并且有充分的理由!
它让我们直接访问网络设备层,允许在链路层传输数据包。这意味着我们可以构建数据包,包括以太网头部,并直接将它们发送到网络接口,绕过更高层的网络层。我们可以使用系统调用创建 AF_PACKET
类型的套接字。在 Go 中,它看起来像这样:
|
|
这行代码创建一个原始套接字,可以在以太网层发送数据包。使用AF_PACKET
时,我们指定SOCK_RAW
表示我们对原始网络协议访问感兴趣。通过将协议设置为ETH_P_IP
,我们告诉内核我们将处理IP数据包。
获得套接字描述符后,我们必须将其绑定到网络接口。这一步可确保我们构建的数据包通过正确的网络设备发送出去:
|
|
使用AF_PACKET
构建数据包涉及手动创建以太网帧。这包括设置源和目标MAC地址以及EtherType,以指示该帧承载的有效负载类型(在我们的例子中是IP)。我们使用了与之前Pcap方法相同的BuildPacket函数。
然后,数据包就可以直接发送到这条链路上了:
|
|
事实证明,AF_PACKET方法的性能几乎与之前使用pcap方法时的性能相同。简单的谷歌搜索显示,libpcap
(tcpdump和Go pcap绑定等工具所使用的底层库)在Linux平台上使用AF_PACKET
进行数据包捕获和注入。所以,这解释了它们的性能相似性。
我们还有一个选项可以尝试。AF_XDP
是一个相对较新的方式!它旨在通过利用传统Linux网络堆栈的快速路径,大幅提高应用程序直接从网络接口卡(NIC)发送和接收数据包的速度。另请参阅我之前关于XDP的博客文章。
AF_XDP
利用了XDP(快速数据路径)框架。这种能力不仅通过避免内核开销提供了最小延迟,而且还通过在软件栈中尽可能早的点进行数据包处理,最大化了吞吐量。
Go标准库并没有原生支持AF_XDP套接字,我只能找到一个库来帮助实现这一点。所以这一切都还很新。
我使用了asavie/xdp这个库,你可以按如下方式初始化一个AF_XDP
套接字。
|
|
注意,我们需要提供一个NIC队列;这清楚地表明我们正在比以前的方法工作在更低的级别上。完整的代码比其他选择要复杂一些,部分原因是我们需要使用用户空间内存缓冲区(UMEM)来存储数据包数据。这种方法减少了内核在数据包处理中的参与,从而缩短了数据包在系统层中传输的时间。通过直接在驱动程序级别构建和注入数据包。因此,请查看我的代码。
结果看起来不错;使用这种方法,我现在可以生成2,647,936个数据包每秒。这是我们之前使用AF_PACKET时性能的两倍!太棒了!
首先,这次做的很有趣,也学到了很多!我们研究了从传统的net.Dial方法生成数据包的各种选项,包括原始套接字、pcap、AF_PACKET,最后是AF_XDP。下面的图表显示了每种方法的数字(都使用一个CPU和一个NIC队列)。AF_XDP是最大的赢家!
如果感兴趣,您可以在类似下面的Linux系统上自行运行基准测试:
|
|
重要的是关注每秒数据包数,因为这是软件网络堆栈的限制。Mb/s
数只是数据包大小乘以您可以生成的每秒数据包数。从传统的net.Dial
方法转换到使用 AF_PACKET
,可以看到轻松实现了两倍的提升。然后,在使用 AF_XDP
时又实现了另外两倍的提升。如果您对快速发送数据包感兴趣,这确实是很重要的信息!
上述基准测试工具默认使用一个 CPU
和一个 NIC 队列
。但是,用户可以选择使用更多的 CPU,这将启动多个 Go 协程以并行执行相同的测试。下面的截图显示了使用 AF_XDP
运行具有 8 个流(和 8 个 CPU)的工具,生成了 186Gb/s
的速率,数据包大小为 1200 字节
(18.8Mpps
)!这对于一台 Linux 服务器来说确实非常令人印象深刻(而且没有使用 DPDK)。比如,比使用 iperf3 更快。
使用 PCAP 方法运行多个流(go 协程)效果不佳。性能会显著下降。相比之下,可比较的 AF_PACKET
方法在多个流和 go 协程下表现良好。
我使用的 AF_XDP
库在大多数硬件 NIC 上似乎表现不佳。我在 GitHub 上提了一个问题,希望能得到解决。如果能更可靠些,那将是很好的,因为这在某种程度上限制了更多真实世界的 AF_XDP
Go 应用。我大部分的测试都是使用 veth 接口进行的;我很想看看它在物理 NIC 和支持 XDP 的驱动程序上的表现。
事实证明,对于 AF_PACKET
,通过使用内存映射(mma
p)环形缓冲区,可以实现零拷贝模式。这个特性允许用户空间应用直接访问内核空间中的数据包数据,无需在内核和用户空间之间复制数据,有效减少了 CPU 使用量并提高了数据包处理速度。这意味着理论上 AF_PACKET
和 AF_XDP
的性能可能非常相似。然而,似乎 Go 的 AF_PACKET
实现不支持零拷贝模式,或者只支持 RX 而不支持 TX。所以我无法使用它。我找到了这个补丁,但不幸的是在一个小时内无法让其工作,所以我放弃了。如果这个补丁有效,这可能是首选的方法,因为你不必依赖 AF_XDP
支持。
最后,我很想在这个 pktgen 库中包含 DPDK 支持。这是唯一缺失的。但这是一个独立的大项目,我需要值得信赖的 Go DPDK 库。也许将来会实现!
]]>在本文中,我们将探讨 Go 中的结构化日志记录,并特别关注这最近推出的 log/slog 软件包, 这个软件包旨在为 Go 带来高性能、结构化和分级的日志记录标准库。
该软件包起源于由 Jonathan Amsterdam 发起的 GitHub 讨论, 后来专门建立了一个提案细化设计。一旦定稿,它在Go v1.21版本中发布。
在以下各节中,我将全面呈现slog的功能, 提供相应的例子。至于它和其它日志框架的性能比较,请参阅此 GitHub 日志框架 benchmark.
让我们从探讨该包的设计和架构开始。它提供了三种您应该熟悉的主要类型:log/slog
Info()
和 Error()
等级别方法来记录感兴趣的事件。Logger
创建的每个独立日志对象的表示形式。Record
的格式化和目的地。该包中包含两个内置处理程序:TextHandler
用于 key=value
输出,JSONHandler
用于 JSON
输出。与大多数 Go 日志库一样, slog
包公开了一个可通过包级别函数访问的默认 Logger
。该 logger
产生的输出与旧的 log.Printf()
方法几乎相同,只是多了日志级别。
|
|
|
|
这是一个有些奇怪的默认设置,因为 slog
的主要目的是为标准库带来结构化日志记录。
不过,通过 slog.New()
方法创建自定义实例就很容易纠正这一点。它接受一个 Handler
接口的实现,用于决定日志的格式化方式和写入位置。
下面是一个使用内置的 JSONHandler
类型将 JSON
日志输出到 stdout
的示例:
|
|
|
|
当使用 TextHandler
类型时,每个日志记录将根据 Logfmt 标准进行格式化:
|
|
|
|
所有Logger
实例默认使用 INFO
级别进行日志记录,这将导致 DEBUG
条目被一直不输出,但您可以根据需要轻松更新日志级别。
自定义默认 logger
最直接的方式是利用 slog.SetDefault()
方法,允许您用自定义的 logger
替换默认的 logger
:
|
|
你现在会观察到,该包的顶层日志记录方法现在会生成如下所示的 JSON 输出:
|
|
使用 SetDefault()
方法还会改变 log
包使用的默认 log.Logger
。这种行为允许利用旧的 log
包的现有应用程序无缝过渡到结构化日志记录。
|
|
|
|
当您需要利用需要 log.Logger
的 API 时(如 http.Server.ErrorLog
),slog.NewLogLogger()
方法也可用于将slog.Logger
转换为 log.Logger
:
|
|
结构化日志记录相对于非结构化格式的一个重大优势是能够在日志记录中以键/值对的形式添加任意属性。
这些属性为所记录的事件提供了额外的上下文信息,对于诸如故障排除、生成指标、审计和各种其他用途等任务非常有价值。
下面是一个示例,说明了如何在 slog 中实现这一点:
|
|
|
|
所有的级别方法(Info()
、Debug()
等)都接受一个日志消息作为第一个参数,之后可以接受无限个宽松类型的键/值对。
这个 API 类似于 Zap
中的 SugaredLogger API(具体是以w结尾的级别方法),它以额外的内存分配为代价来换取简洁性。
但要小心,因为这种方法可能会导致意外的问题。具体来说,不匹配的键/值对会导致输出存在问题:
|
|
由于time_taken_ms键没有对应的值,它将被视为以!BADKEY作为键的值。这不太理想,因为属性对齐错误可能会创建错误的条目,而您可能要等到需要使用日志时才会发现。
|
|
为了防止此类问题,您可以运行vet命令或使用lint工具自动报告此类问题:
|
|
另一种防止此类错误的方法是使用如下所示的强类型上下文属性:
|
|
虽然这是上下文日志记录的一种更好的方法,但它并不是万无一失的,因为没有什么能阻止您混合使用强类型和宽松类型的键/值对,就像这样:
|
|
要在为记录添加上下文属性时保证类型安全,您必须使用 LogAttrs()
方法,像这样:
|
|
这个方法只接受 slog.Attr
类型的自定义属性,因此不可能出现不平衡的键/值对。然而,它的 API 更加复杂,因为除了日志消息和自定义属性外,您总是需要向该方法传递上下文(或 nil)和日志级别。
Slog 还允许在单个名称下对多个属性进行分组,但输出取决于所使用的 Handler
。例如,对于 JSONHandler
,每个组都嵌套在 JSON
对象中:
|
|
|
|
当使用 TextHandler 时,组中的每个键都将以组名作为前缀,如下所示:
|
|
在特定范围内的所有记录中包含相同的属性,可以确保它们的存在而无需重复的日志记录语句,这是很有益的。
这就是子 logger
可以派上用场的地方,它创建了一个新的日志记录上下文,该上下文从其父级继承而来,同时允许包含额外的字段。
在 slog
中,创建子 logger
是使用 Logger.With()
方法完成的。它接受一个或多个键/值对,并返回一个包含指定属性的新 Logger
。
考虑以下代码片段,它将程序的进程 ID
和用于编译的 Go 版本
添加到每个日志记录中,并将它们存储在 program_info
属性中:
|
|
有了这个配置,所有由子日志记录器创建的记录都会包含 program_info 属性下指定的属性,只要它在日志点没有被覆盖。
|
|
|
|
您还可以使用 WithGroup()
方法创建一个子日志记录器,该方法会启动一个组,以便添加到日志记录器中的所有属性(包括在日志点添加的属性)都嵌套在组名下:
|
|
|
|
log/slog 软件包默认提供四个日志级别,每个级别都与一个整数值相关联:
每个级别之间间隔 4 是经过深思熟虑的设计决策,目的是为了适应在默认级别之间使用自定义级别的日志记录方案。例如,您可以使用 1、2 或 3 的值创建介于 INFO
和 WARN
之间的新日志级别。
我们之前看到过,默认情况下所有日志记录器都配置为 INFO
级别记录日志,这会导致低于该严重性(例如 DEBUG
)的事件被忽略。您可以通过以下所示的 HandlerOptions
类型来自定义此行为:
|
|
|
|
这种设置日志级别的方法会固定处理程序在整个生命周期内的日志级别。如果您需要动态调整最低日志级别,则必须使用 LevelVar
类型,如下所示:
|
|
之后您可以随时使用以下方式更新日志级别:
|
|
如果您需要超出 slog 默认提供的日志级别,可以通过实现 Leveler
接口来创建它们。该接口的签名如下:
|
|
实现这个接口很简单,可以使用下面展示的 Level
类型(因为 Level
本身就实现了 Leveler
接口):
|
|
一旦您像上面那样定义了自定义日志级别,您就只能通过 Log()
或 LogAttrs()
方法来使用它们:
|
|
|
|
注意到自定义日志级别会使用默认级别的名称进行标注。这显然不是您想要的,因此应该通过 HandlerOptions
类型来自定义级别名称,如下所示:
|
|
ReplaceAttr()
函数用于自定义 Handler
如何处理 Recor
d 中的每个键值对。它可以用来修改键名,或者以某种方式处理值。
在给定的示例中,它将自定义日志级别映射到它们各自的标签,分别生成 TRACE
和 FATAL
:
|
|
正如之前提到的,TextHandler
和 JSONHandler
都可以使用 HandlerOptions
类型进行定制。您已经看过如何调整最低日志级别以及在记录日志之前修改属性。
通过 HandlerOptions
还可实现的其他自定义功能包括(如果需要的话):
|
|
|
|
根据应用环境切换日志处理程序也非常简单。例如,您可能更喜欢在开发过程中使用 TextHandler
,因为它更易于阅读,然后在生产环境中切换到 JSONHandler
以获得更大的灵活性并兼容各种日志工具。
这种行为可以通过环境变量轻松实现:
|
|
|
|
|
|
|
|
|
|
由于 Handler 是一个接口,因此可以创建自定义处理程序来以不同的格式格式化日志或将它们写入其他目的地。
该接口的签名如下:
|
|
以下是每个方法的解释:
|
|
你的代码使用PrettyHandler
的方式如下:
|
|
当你执行程序时你会看到如下彩色的输出:
你可以在 GitHub 和这篇 Go Wiki 页面上找到社区创建的几个自定义处理程序。 一些值得关注的例子包括:
到目前为止,我们主要使用的是诸如 Info()
和 Debug()
等标准级别的函数,但 slog 还提供了支持 context
的变体,这些变体将 context.Context
值作为第一个参数。下面是每个函数的签名:
|
|
使用这些方法,您可以通过将上下文属性存储在 Context
中来跨函数传播它们,这样当找到这些值时,它们就会被添加到任何生成的日志记录中。
请考虑以下程序:
|
|
在代码中,我们向 ctx
变量添加了一个 request_id
并传递给了 InfoContext
方法。然而,运行程序后,日志中却没有出现 request_id
字段。
|
|
为了实现这一功能,你需要创建一个自定义处理程序并重新实现 Handle
方法,如下所示:
|
|
ContextHandler
结构体嵌入了 slog.Handler
接口,并实现了 Handle
方法。该方法的作用是提取提供者上下文 (context) 中存储的 Slog 属性。如果找到这些属性,它们将被添加到日志记录 (Record
) 中。 然后,底层的处理程序 (Handler) 会被调用,负责格式化并输出这条日志记录。
另一方面,AppendCtx
函数用于向 context.Context
中添加 Slog 属性。它使用 slogFields
这个键作为标识符,使得 ContextHandler
能够访问这些属性。
下面将介绍如何同时使用这两个部分来让 request_id 信息出现在日志中:
|
|
现在您将观察到,使用 ctx 参数创建的任何日志记录中都包含了 request_id
信息。
|
|
与大多数框架不同,slog 没有为 error
类型提供特定的日志记录助手函数。因此,您需要像这样使用 slog.Any()
记录错误:
|
|
|
|
为了获取并记录错误的堆栈跟踪信息,您可以使用诸如 xerrors 之类的库来创建带有堆栈跟踪的错误对象:
|
|
为了在错误日志中看到堆栈跟踪信息,您还需要像之前提到的 ReplaceAttr()
函数一样,提取、格式化并将其添加到对应的 Record 中。
下面是一个例子:
|
|
结合以上步骤,任何使用 xerrors.New()
创建的错误都将以如下格式记录,其中包含格式良好的堆栈跟踪信息:
|
|
现在您可以轻松跟踪应用程序中任何意外错误的执行路径。
LogValuer
接口允许您通过指定自定义类型的日志输出方式来标准化日志记录。以下是该接口的签名:
|
|
实现了该接口的主要用途之一是在自定义类型中隐藏敏感字段。例如,这里有一个 User
类型,它没有实现 LogValuer
接口。请注意,当实例被记录时,敏感细节是如何暴露出来的:
|
|
|
|
由于该类型包含不应该出现在日志中的秘密字段(例如电子邮件和密码),这会带来问题,并且也可能使您的日志变得冗长。
您可以通过指定类型在日志中的表示方式来解决这个问题。例如,您可以仅指定记录 ID
字段,如下所示:
|
|
You will now observe the following output:
|
|
除了隐藏敏感字段,您还可以像这样对多个属性进行分组:
|
|
|
|
slog 设计的主要目标之一是在 Go 应用程序中提供统一的日志记录前端(slog.Logger
),同时后端(slog.Handler
)可以根据程序进行定制。
这样一来,即使后端不同,所有依赖项使用的日志记录 API 都是一致的。 同时,通过使切换不同的后端变得简单,避免了将日志记录实现与特定包耦合在一起。
以下示例展示了将 Slog 前端与 Zap 后端结合使用,可以实现两者的优势:
|
|
|
|
该代码片段创建了一个新的 Zap
生产环境日志记录器,然后通过 zapslog.NewHandler()
将其用作 Slog 包的处理程序。配置完成后,您只需使用 slog.Logger
提供的方法写入日志,生成的日志记录将根据提供的 zapL
配置进行处理。
|
|
由于日志记录是通过 slog.Logger
来完成的,因此切换到不同的日志记录器真的非常简单。例如,你可以像这样从 Zap
切换到 Zerolog
:
|
|
|
|
|
|
在上面的代码片段中,Zap
处理器已被自定义的 Zerolog
处理器替换。由于日志记录不是使用任何库的自定义 API 完成的,因此迁移过程只需几分钟,相比之下,如果你需要在整个应用程序中从一个日志记录 API 切换到另一个,那将需要更长时间。
一旦你配置了 Slog 或你偏好的第三方 Go 日志记录框架,为确保你能够从应用程序日志中获得最大价值,有必要采用以下最佳实践:
1、标准化你的日志接口
实现 LogValuer
接口可以确保你的应用程序中的各种类型在日志记录时的表现一致。这也是确保敏感字段不被包含在应用程序日志中的有效策略,正如我们在本文前面所探讨的。
2、在错误日志中添加堆栈跟踪
为了提高在生产环境中调试意外问题的能力,你应该在错误日志中添加堆栈跟踪。这样,你就能够更容易地定位错误在代码库中的起源以及导致问题的程序流程。
目前,Slog 并没有提供将堆栈跟踪添加到错误中的内置方式,但正如我们之前所展示的,可以使用像 pkgerrors 或 go-xerrors 这样的包,配合一些辅助函数来实现这一功能。
3、Lint Slog 语句以确保一致性
Slog API 的一个主要缺点是它允许两种不同类型的参数,这可能导致代码库中的不一致性。除此之外,你还希望强制执行一致的键名约定(如 snake_case、camelCase 等),或者要求日志调用始终包括上下文参数。
像 sloglint 这样的 linter 可以帮助你基于你偏好的代码风格来强制执行 Slog 的各种规则。以下是通过 golangci-lint 使用时的一个示例配置:
|
|
4、集中管理日志,但首先将其持久化到本地文件
将日志记录与将它们发送到集中式日志管理系统的任务解耦通常是更好的做法。首先将日志写入本地文件可以确保在日志管理系统或网络出现问题时有一个备份,防止重要数据的潜在丢失。
此外,在发送日志之前先将其存储在本地,有助于缓冲日志,允许批量传输以优化网络带宽的使用,并最小化对应用程序性能的影响。
本地日志存储还提供了更大的灵活性,因此如果需要过渡到不同的日志管理系统,则只需修改发送方法,而无需修改整个应用程序的日志记录机制。有关使用 Vector 或 Fluentd 等专用日志发送程序的更多详细信息,请参阅我们的文章。
将日志记录到文件并不一定要求你配置所选的框架直接写入文件,因为 Systemd 可以轻松地将应用程序的标准输出和错误流重定向到文件。Docker 也默认收集发送到这两个流的所有数据,并将它们路由到主机上的本地文件。
5、对日志进行采样
日志采样是一种只记录日志条目中具有代表性的子集,而不是记录每个日志事件的做法。在高流量环境中,系统会产生大量的日志数据,而处理每个条目可能成本高昂,因为集中式日志解决方案通常根据数据摄入率或存储量进行收费,因此这种技术非常有用:
|
|
|
|
第三方框架,如 Zerolog 和 Zap,提供了内置的日志采样功能。在使用 Slog 时,你需要集成一个第三方处理器,如 slog-sampling,或开发一个自定义解决方案。你还可以通过专用的日志发送程序(如 Vector)来选择对日志进行采样。
6、使用日志管理服务
将日志集中在一个日志管理系统中,可以方便地跨多个服务器和环境搜索、分析和监控应用程序的行为。将所有日志集中在一个地方,可以显著提高你识别和诊断问题的能力,因为你不再需要在不同的服务器之间跳转来收集有关你的服务的信息。
虽然市面上有很多日志管理解决方案,但 Better Stack 提供了一种简单的方法,可以在几分钟内设置集中式日志管理,其内置了实时追踪、报警、仪表板、正常运行时间监控和事件管理功能,并通过现代化和直观的用户界面进行展示。在这里,你可以通过完全免费的计划来试用它。
我希望这篇文章能让你对 Go 语言中新的结构化日志包有所了解,以及如何在你的项目中使用它。如果你想进一步探索这个话题,我建议你查看完整的提案和包文档。
感谢阅读,祝你在日志记录方面一切顺利!
q其他一些和slog有关的资源。
提供日志格式化的库。
用于丰富日志记录的处理程序。
一些厂家提供了将slog转发到它们平台上的功能,比如datadog、sentry等,这里就不赘述了,因为这些都是第三方的服务,在国内也不太合适使用。其他一些转发的库:
其他日志库的适配器。
我们使用堆的时候,一般希望有Heap
这样一个对象,并且能指定它是“小根堆”或者"大根堆"。我们希望这个类型有Push
和Pop
方法,可以加入一个元素或者弹出(最小的)元素。
我们期望这个Heap
支持泛型的,任何可以比较的类型都可以使用。
处于简化的考虑,我们实现的Heap
不考虑线程安全。如果要保证线程安全,可以使用sync.Mutex
来保护Heap
的操作。
我们实现的Heap
类型的操作基于标准库的操作,只不过我们封装了一下,让它更加友好。
我们能够基于既有的一个slice创建Heap
,也可以基于一个空的Heap
创建一个新的Heap
。
最终我们实现了一个友好的堆,你可以在github上查看它的代码binheap。
首先定义一个binHeap
,这是一个泛型的slice,用来保存堆的元素,这样用户就不用定义这样一个类型了,简化了用户的使用。默认是小根堆。所有元素类型需要满足cmp.Ordered
接口,可以进行大小比较。这个接口是标准库中的接口,如果你还不知道cmp
包,那么需要刷新刷新Go新的变化了:
|
|
这样我们就可以定义一个BinHeap
类型,它是binHeap
的封装,它有maxHeap
字段,用来表示是小根堆还是大根堆。BinHeap
类型有Push
和Pop
方法,可以加入一个元素或者弹出(最小的)元素。
|
|
另外还附送了两个常用的方法Len
和Peek
,Len
返回堆的大小,Peek
返回堆顶元素但是并不会从堆中移除它,在和堆的最小值做比较的时候很有用。
|
|
最后,我们还提供了一个BinHeapOption
类型,用来设置BinHeap
的属性,比如是小根堆还是大根堆;为了提高性能,如果预先已经知道堆的大小,可以在初始化的时候就进行设置。
|
|
这样我们就实现了一个友好的堆。
当然,如果你已经有了一个slice: []V
, 想把它转换成堆,并且在这个slice上进行堆操作,那么你可以使用下面的方法:
|
|
堆的操作sift_down
和sift_up
的堆和核心操作,也来源子标准库的代码,只不过我把它们改成成泛型的函数了:
|
|
有时候我不会要求面试者写出答案,首先我听一下他的思想,如果写代码困难的话我都允许可以上网查标准库的文档,看看heap的用法。
相对来说比Redis的作者antirez的面试要轻松些了,他的面试题是要求面试者写出一个二叉搜索树。
这道题既然是经典题,很很多教科书或者算法网站上都有,比如leetcode也有,收录在Leetcode 算法题解精选一书中。
我们可以采用快速排序的方法实现。
而且,因为我们只关心第K个最大的元素,我们只需要找出这个元素,不需要对整个数组进行完全排序。
|
|
递归一向都是难以理解,但是理解之后有觉得非常妙。
这道题一个比较简单直观的解答就是堆排序。
我们使用一个最小堆,堆的大小为k,然后遍历数组,如果堆的大小小于k,就把元素放入堆中,如果堆的大小等于k,就把堆顶元素(当前堆内最小的元素)和当前元素比较,如果堆顶元素小于当前元素,就把堆顶元素弹出,把当前元素放入堆中。
|
|
其实,Go标准库中提供了heap,我们自己不用实现。使用标准库中的heap,我们可以将上面的代码改写为:
|
|
虽然标准库实现了heap,但是实现的非常难用:
sort.Interface
),而且还要实现Push(x any)
和
Pop() any`两个方法。heap.Init
和heap.Fix
、heap.Pop
、heap.Push
、heap.Remove
等方法,Pop和Push和Interface
的Pop
和Push
方法重名,这样会让人困惑。heap.Pop
和Interface.Pop
没有一点关系。heap.Push
和Interface.Push
没有一点关系。不要以为heap.Push
直接调用Interface.Pop
,虽然内部会调用,但是都有额外的处理每次使用,心智负担很重,不是不会写,而是性价比很低。貌似看起来很灵活,提供了很多方法,但是实际使用的时候,却很麻烦。
我想我不是唯一一个和我有相同感受的人,比如这个Why Are Golang Heaps So Complicated
这篇文章还还提供了一个简单的堆实现,可以参考。
有时候我不会要求面试者写出答案,首先我听一下他的思想,如果写代码困难的话我都允许可以上网查标准库的文档,看看heap的用法。
相对来说比Redis的作者antirez的面试要轻松些了,他的面试题是要求面试者写出一个二叉搜索树。
]]>为啥在使用
a[i:i+4:i+4]
而不是a[i:i+4]
?
本文第一部分先回答这个问题。 第二部分介绍更好的边界检查消除方法。 第三部分再全面梳理Go的边界检查消除技术。
a[i:i+4:i+4]
而不是 a[i:i+4]
?这篇文章发布到几个平台之后,很多Gopher都在问这个问题的答案,包括《100个Go语言典型错误》的作者也在twitter上询问,再比如Hacker News上的讨论,reddit。
当然,还每看过这篇文章的同学还不明白前因后果,这里我再简单介绍一下。SourceGraph工程师使用BCE做优化,他的代码如下,
|
|
注意做边界检查的那两行,它们做了边界检查,新的slice的len和cap都是4,可以确保aTemp[0]
、aTemp[3]
、bTemp[0]
、bTemp[3]
不会越界,所以下面四行不用做边界检查了。不过边界检查少了很多的指令,可以提高性能。
我们怎么知道哪一行做了边界检查呢?可以使用下面的命令编译,会把做边界检查的行数打印出来。
可以看到结果和我们注释中的一样,只在第14、15行做了边界检查。
但是话锋一转,SourceGraph工程师突然问了一个问题:为啥这两行使用 a[i:i+4:i+4]
而不是 a[i:i+4]
?
难道a[i:i+4]
会导致下面四行做边界检查吗?这个问题让很多人都很好奇,几个论坛上没有答案。我翻译的文章评论中也有小伙伴问这个问题。
首先,我们修改为a[i:i+4]
,然后编译,看看结果:
没什么区别,还是在第14、15行做了边界检查,接下来四行做了边界检查消除,不一样么?
这个问题 Go101 老貘 在Twitter上提了一下,没有展开讲,购买了他的书的同学可以看看那一章:
接下来我从最开始的讨论讲起,那还得从2018年的秋天的一个提交讲起。
不想看历史的同学可以直接跳过去,结论就是:这样写不是为了边界检查消除,而是为了性能。
这是一次对image/draw: optimize bounds checks in loops做边界检查的优化:
其中有一行s := spix[i : i+4 : i+4]
,Sebastien Binet提出一个疑问,为啥这里要设置cap? Brad Fitzpatrick就说那我移除好了,作者Ian Davis说我做了测试,感觉设置cap会给编译器提示,性能更好。大家就对这个奇怪的点展开了有趣的讨论,Ian Davis说如果改成s := spix[i : i+4]
虽然对边界检查没有影响,但是性能会下降。Giovanni Bajo给出了正解:
If you don't specify the cap, the compiler needs to calculate it computing newcap = oldcap - offset. If you specify it with the same value of len, it does less work.
翻译:如果你不指定cap,编译器需要计算新的
newcap = oldcap - offset
。如果你指定cap的值和len一样,编译器就可以少做点工作。
Nigel Tao也指出,这行代码也可以使用_ = spix[i+3]
代替。
最终这个讨论记录在#27857。
回答SourceGraph工程师的问题:为啥在使用 a[i:i+4:i+4]
而不是 a[i:i+4]
?
答案是为了更好的性能,而不是为了边界检查消除。
SourceGraph工程师的代码使用BCE做了优化,但是你还是看到,有两行代码还是做了边界检查,这是因为Go的BCE并不完美,有时候还是会做边界检查。
但是有没有办法全部消除代码的边界检查呢?老貘还是给出了一个解决方案。
我们先看看老貘的给出的例子(f8z
我略有改动):
可以看到,Go的BCE还不是那么智能,f8x
例子中s[i+3]
、s[i+2]
、s[i+1]不会越界,但是这三行还是做了边界检查。
f8y
例子中s[3]
做了边界检查后,可以保证s[2]
、s[1]
、s[0]
不会越界,所以这三行不用做边界检查。
f8z
例子中,每次循环我们都会检查s的长度是否大于4,如果大于4,s[3]
、s[2]
、s[1]
、s[0]
肯定不会越界,所以这四行不做边界检查,而且s = s[4:]
也不会越界。这样这个实现就整体都不需要做边界检查了
所以SourceGraph工程师的代码可以改成下面这样:
老貘在Go101中有一章专门讲了这个问题,感兴趣的同学可以直接阅读,或者购买他的电子书。
我想从Go实现边界检查消除的提议说起,这个提议是cmd/compile: unnecessary bounds checks are not removed #14808。
当然BCE在Go的编译器中也一直做优化,原始的文档整理也不能全面反映现状,但是还是很有意义的,整理的BCE技术进行分类便于学习。我就整理翻译一下。
Go的边界检查有两个:索引a[i]
和slicea[i,j]
。Go编译器在访问这两种方式的时候会插入一些边界检查代码,但是大部分情况下是不需要的,冗余的,我们的目标就是在编译的时候去掉这些冗余的检查,这样能提供性能,检查二进制文件大小。通过-gcflags=-B
可以禁止边界检查。
你可以通过go build -gcflags="-d=ssa/check_bce" xxx.go
查看哪些行进行了边界检查。
下面是一些进行边界检查消除的场景。
比如下面的代码中,重复的索引和切片访问就不做边界检查了。
|
|
|
|
|
|
这也是上一节我们完全消除边界检查的方式。
|
|
a[i:j]
会产生两个边界检查: 0 <= i <= j
和 0 <= j <= cap(a)
。有时候我们可以移除它们中的一个。
|
|
|
|
|
|
下面这个k8x
中第一个a[i]
中i可能为负数,所以不能移除,接下来的两个可以确保不越界,所以可以移除。
|
|
下面这个k8y
中i+2
可能溢出,比如i = math.MaxInt
, 所以不能移除。
|
|
`
这个文档中有几处场景已经BCE已经完善了,我更正过来了,如上。
]]>为啥在使用
a[i:i+4:i+4]
而不是a[i:i+4]
?
本文第一部分先回答这个问题。 第二部分介绍更好的边界检查消除方法。 第三部分再全面梳理Go的边界检查消除技术。
]]>大家可能好奇我写书的时候是用什么工具?正好最近我画架构图的时候使用Excelidraw,也试用了几款其他的工具。而且这两天搭建了一个备忘录的工具,所以正好总结一下。
我在写书稿的时候曾经一度想使用Latex,因为它强大的排版功能几乎可以排版任何东西,而且拥有丰富的插件,输出的pdf效果也很好,但是考虑到交稿给编辑,扁编辑在审稿和校对的时候不方便,而且书稿出版社会使用自己的版式重新排版,虽然我一度整了一个Latex模板并尝试写电子书,但是最终也放弃了。
基本上,我写的书稿是使用Markdown的格式。Markdown的格式对于写作还是很方便的,而且不需要考虑太多的版面的问题,方便组织层次,插入图片和代码也都很方便,编译也容易导出到其他格式。
使用vscode 就可以编写Markdown的文本,后来我听说Typora,这是一个很好的Markdown编辑器,支持实时预览,而且支持导出PDF,网上都说使用方便,我也尝试了一下,也确实不错。不过自1.0版本后它开始收费了。
当然收费也无可厚非,毕竟开发者也需要生活,不能够纯用爱发电,对吧? 但是我还是希望找到一个免费的工具,最终我找到了MarkText,这是一个开源的Markdown编辑器,支持实时预览,而且支持导出PDF,这是我最喜欢的一个Markdown编辑器。
所以我就使用MarkText平替Typora来写书稿,完成了《深入理解Go并发编程》的写作。
这个工具的作者还是中国的同学,非常的赞。不过目前代码活跃度不是那么高了,不过对我来说已经够用了,希望它后续能够支持插件,这样可以充分发挥网友的聪明才智,把功能丰富起来。
首先做笔记的工具很多,比如Notion、Google Keep、Evernote(印象笔记)、OneNote、Simplenote、Bear、GoodNotes、Notability等等,很多,但是很多都是收费的,而且基本是web服务,意味着你的数据都在别人的服务器上,不安全, 而且服务可用性也不可控。
所以我想找的是一个桌面工具,免费的,相应的笔记我通过git保存在github或者自建的git服务器上即可。
有两个工具值得推荐。
第一个是 obsidian, 这是一个免费的笔记工具,支持markdown格式,而且支持插件,可以自己写插件,而且支持本地存储,不需要联网,而且支持git,可以把笔记存储在github上.
它具有以下有点:
第二个是 logseq,logseq是一个开源的网络化个人知识管理和协作工具,具有以下主要特点:
我在Mac上安装Logseq后发现它的功能虽然强大,插件更丰富,但是弹出的对话框不能关闭,后台启动多个进程,占用资源比较大,反应迟钝,这可能是它基于Electron开发的原因,所以我最后卸载了。
其实这两个工具在我的日常生活中都没有使用,我就使用苹果的备忘录,便捷,在多个苹果设备上可以互通。不好的地方在于它过于简单了。
其实我之所以考察obsidian和logseq,是因为我最近在画几个技术相关的架构图或者插图。先前较多使用draw.io,这是一个免费的在线画图工具,支持多种图形,而且支持导出多种格式,而且支持保存到本地。不过看看到网上其他同学的架构图图画的都挺好,使用excalidraw画的,有手工画图的风格,非常的漂亮,所以我尝试使用excalidraw画图。
excalidraw默认就配置了三种字体:手写、正常和代码字体。对于我们来说,我们希望对于中文,能够使用一款漂亮的字体做渲染,但是excalidraw官方的网站不支持,有些人通过浏览器插件等方式把其中的字体做替换来达到目的,过于麻烦。
我搜了一下网上的教程,说obsidian很好的支持了excalidraw插件,可以配置自己的字体作为第四种可选择的字体,这就非常好了。
你可以选择一款自己喜欢的字体,放在Obsidian Vault/Excalidraw/font
中,我选择了“沐瑶随心手写体”,这是一款免费且好看的中文字体,然后在obsidian的设置中配置excalidraw插件,就可以选择这个字体了。
然后我就可以使用excalidraw画图了,可以看到Font family
中多了一个字体选项,这就是我配置的字体。
obsidian也支持了excalidraw的脚本,这极大的丰富了excalidraw的功能,可以画出更加复杂的图形。而且excalidraw插件也支持资源库,我上面图中的gopher就是使用的资源库中的资源。
现在看起来 obsidian + excalidraw插件可以很好的满足我的需求了。
前面提到,我基本使用苹果的备忘录,但是它的功能太简单了,而且不支持markdown格式,所以我想找一个支持markdown格式的备忘录工具,最好是web方式的,这样我在电脑、平板和手机上都可以自如的编写备忘录了。
经常我脑海中会蹦出一点火花,可能是一个新的项目想法,可能是一个新的技术点,可能是一个新的文章的思路,也可能是一本书的大纲,我书包中常备着一个小本子,当这些想法闪现的时候,我都会拿出本子记录下来。但是经常在一些时候,比如外出的时候,或者半夜有想法失眠的时候,本子不在身边,我生怕这些稍纵即逝的想法就这样消失了。所以如果有一个随时随地的备忘录工具就好了,不仅限于苹果系统。
其实上面笔记工具提到的google keep就挺好,但是有时候访问Google并不是那么方便,而且基于Goole历来的表现,说不定哪一天就把这个产品关闭了。
不过这个工具确实好, 支持代办列表、图片,甚至可以绘图。
当然,类似的备忘录工具有很多,比如这篇知乎文章介绍的。
这两天,我看到了一款非常简洁但是功能有很丰富的备忘录工具,支持自己搭建,我就自己搭建了一个。
这款备忘录工具就是memos, 你可以访问它的示例网站进行试用,示例网站地址是: https://demo.usememos.com/。
usememos/memos
是一个开源的轻量级便签服务,可以让用户轻松捕获和分享想法。它的主要特点包括:
最重要的一点,你可以自己通过docker或者直接编译源码来搭建自己的备忘录服务,这样你的数据就不会存储在别人的服务器上,安全性更高。而且可以一键部署,安装简单。
不但安装简单,而且使用也非常简单,你可以在网站上注册一个账号,然后就可以使用了。
初始第一个用户是管理员,你可以设置是否允许其他用户注册等,控制这个应用你自己使用还是当成平台共享。
我在自己的服务器上部署了这个程序,并设置了相应的域名 https://memos.rpcx.io , 目前仅限于我自己使用。而且我手机上利用浏览器的功能,创建了一个桌面快捷键,直接点击桌面图标就可以进入,类似桌面APP的效果。
因为memos是开源的,我也进行了相应的修改,比如图标换成我微信的图标, 备忘录的字体也换成了“沐瑶随心手写体”,更有趣一些。汉化了一些不彻底的地方。
]]>Pinner
是一组固定的 Go 对象。可以使用 Pin
方法来固定一个对象。Pinner
固定的所有对象都可以使用 Unpin
方法解开固定。
Pinner.Pin
是 Go 语言中用于防止对象被垃圾回收器回收的函数。它接受一个指针参数,并将该指针指向的内存区域标记为不可移动。这意味着,即使该对象不再被任何变量引用,它也不会被回收,直到调用 Pinner.Unpin
函数将其取消固定。
Pinner.Pin
通常用于以下场景:
以下示例演示了如何使用 Pinner.Pin
函数:
|
|
Pinner.Pin
函数:new
函数创建的对象Pinner.Pin
函数,会导致程序崩溃。Pinner.Unpin
函数之前,必须确保不再使用该对象,否则会导致程序运行时错误Pinner
是一组固定的 Go 对象。可以使用 Pin
方法来固定一个对象。Pinner
固定的所有对象都可以使用 Unpin
方法解开固定。
工程师贡纳尔·莫林在元旦发起一个挑战(1BRC),挑战从 1 月 1 日持续到 1 月 31 日。
如果你决定接受它,你的任务看似简单: 编写一个 Java 程序,用于从文本文件中检索温度测量值并计算每个气象站的最小、平均值和最高温度。
只有一点需要注意:文件有 1,000,000,000 行!(1 billion, 10亿行)。
这个文本文件结构简单,每行一个测量值, 气象站和它的测量温度:
|
|
程序应打印出每个站的最小值、平均值和最大值,按字母顺序排列,如下所示:
|
|
1BRC挑战的目标是为这项任务创建最快的实现, 在这样做的同时,探索现代 Java 的好处,并找出你可以将这个平台推向多远。 因此,使用所有的(虚拟)线程,利用 Vector API 和 SIMD,优化你的 GC,利用 AOT 编译,或者使用你能想到的任何其他技巧。
没想到莫林提出这个挑战后,一下子火了,在多个平台上都进行了热烈的讨论:Hacker News、lobste.rs、Reddit。
而且,实现也不再仅限于Java,其他编程语言甚至数据库都有实现。
在32 core AMD EPYC™ 7502P (Zen2), 128 GB RAM的服务器上,使用8个核,优化的Java程序在使用GraalVM native binary情况下已经跑到了1秒多。在我的Mac M2 mini上可以跑到16.42秒。
我们关注一下Go语言的实现。
一个简单的中规中矩的Go语言实现是mr-karan/1brc-go, 在我的Mac M2 mini机器可以跑到26.66秒。
作者专门写了一篇文章介绍优化的步骤:
另一个Go语言的实现是shraddhaag/1brc, 他把他的优化步骤都在README中列出来了,在我的Mac M2 mini机器可以跑到23.73秒。
Attempt Number | Approach | Execution Time | Diff | Commit |
---|---|---|---|---|
0 | Naive Implementation: Read temperatures into a map of cities. Iterate serially over each key (city) in map to find min, max and average temperatures. | 6:13.15 | ||
1 | Evaluate each city in map concurrently using goroutines. | 4:32.80 | -100.35 | 8bd5f43 |
2 | Remove sorting float64 slices. Calculate min, max and average by iterating. | 4:25.59 | -7.21 | 830e5df |
3 | Decouple reading and processing of file content. A buffered goroutine is used to communicate between the two processes. | 5:22.83 | +57.24 | 2babf7d |
4 | Instead of sending each line to the channel, now sending 100 lines chunked together. Also, to minimise garbage collection, not freeing up memory when resetting a slice. | 3:41.76 | -161.07 | b7b1781 |
5 | Read file in chunks of 100 MB instead of reading line by line. | 3:32.62 | -9.14 | c26fea4 |
6 | Convert temperature from string to int64 , process in int64 and convert to float64 at the end. |
2:51.50 | -41.14 | 7812da4 |
7 | In the city <> temperatures map, replaced the value for each key (city) to preprocessed min, max, count and sum of all temperatures instead of storing all recorded temperatures for the city. | 1:39.81 | -71.79 | e5213a8 |
8 | Use producer consumer pattern to read file in chunks and process the chunks in parallel. | 1:43.82 | +14.01 | 067f2a4 |
9 | Reduce memory allocation by processing each read chunk into a map. Result channel now can collate the smaller processed chunk maps. | 0:28.544 | -75.286 | d4153ac |
10 | Avoid string concatenation overhead by not reading the decimal point when processing city temperature. | 0:24.571 | -3.973 | 90f2fe1 |
11 | Convert byte slice to string directly instead of using a strings.Builder . |
0:18.910 | -5.761 |
然后我们再看一个已经merge到官方库的Go语言实现,在我的Mac M2 mini机器可以跑到17.79秒,相当不错了。
他的代码在这里,他也做了很多的优化,比如使用mmap。
这几个代码都是值得学习和研究的,我们不能说他们做了最好的优化,但是确实都是花了不少功夫的。
几个rust的实现也是值得学习的。在我的Mac M2 mini机器的跑分:
一个C语言的实现:
这是一个关于某函数的故事,这个函数被大量调用,而且这些调用都在关键路径上。让我们来看看如何让它变快。
剧透一下,这个函数是一个点积函数。
点积(Dot Product),也称为内积或数量积,是一种数学运算,通常用于计算两个向量之间的乘积。点积的结果是一个标量(即一个实数),而不是一个向量。
假设有两个向量:
$$\mathbf{a}=\begin{bmatrix}a_1\\a_2\\\vdots\\a_n\end{bmatrix}$$
$$\mathbf{b}=\begin{bmatrix}b_1\\b_2\\\vdots\\b_n\end{bmatrix}$$那么,这两个向量的点积为:
$$\mathbf{a}\cdot\mathbf{b}=\sum_{i=1}^n a_ib_i=a_1b_1+a_2b_2+\cdots+a_nb_n$$
在 Sourcegraph,我们正在开发一个名为 Cody 的 Code AI 工具。为了让 Cody 能够很好地回答问题,我们需要给它足够的上下文。我们做的一种方式是利用嵌入(embedding
)。
为了我们的目的,嵌入是文本块的向量表示。它们用某种方式构建,以便语义上相似的文本块具有更相似的向量。当 Cody 需要更多信息来回答查询时,我们在嵌入上运行相似性搜索,以获取一组相关的代码块,并将这些结果提供给 Cody,以提高结果的相关性。
和这篇文章相关的部分是相似度度量
,它是一个函数,用于判断两个向量有多相似。对于相似性搜索,常见的度量是余弦相似度。然而,对于归一化向量(单位幅度的向量),点积产生的排名与余弦相似度是等价的。为了运行一次搜索,我们计算数据集中每个嵌入的点积,并保留前几个结果。由于我们在得到必要的上下文之前无法开始执行 LLM,因此优化这一步至关重要。
你可能会想:为什么不使用索引向量数据库?除了添加我们需要管理的另一个基础设施外,索引的构建会增加延迟并增加资源需求。此外,标准的最近邻索引只提供近似检索,这与更易于解释的穷举搜索相比,增加了另一层模糊性。鉴于这一点,我们决定在我们的手工解决方案中投入一点精力,看看我们能走多远。
下面的代码是一个计算两个向量点积的简单的Go函数实现。我的目标是刻画出我为优化这个函数所采取的方法,并分享我在这个过程中学到的一些工具。
|
|
除非另有说明,否则所有基准都在 Intel Xeon Platinum 8481C 2.70GHz CPU
上运行。这是一个 c3-highcpu-44
GCE VM。本博客文章中的代码都可以在这里找到。
现代的CPU都有一个叫做指令流水线的东西,它可以同时运行多条指令,如果它们之间没有数据依赖的话。数据依赖只是意味着一个指令的输入取决于另一个指令的输出。
在我们的简单实现中,我们的循环迭代之间有数据依赖。实际上,每个迭代都有一个读/写对,这意味着一个迭代不能开始执行,直到前一个迭代完成。
一个常见的方法是在循环中展开一些迭代,这样我们就可以在没有数据依赖的情况下执行更多的指令。此外,它将固定的循环开销(增量和比较)分摊到多个操作中。
|
|
在我们的展开代码中,乘法指令的依赖关系被移除了,这使得CPU可以更好地利用流水线。这使我们的吞吐量比我们的简单实现提高了37%。
注意,我们实际上可以通过调整我们展开的迭代次数来进一步提高性能。在基准机器上,8似乎是最佳的,但在我的笔记本电脑上,4的性能最好。然而,改进是与平台相关的,而且改进相当微小,所以在本文的其余部分,我将使用4个展开深度来提高可读性。
为了防止越界的切片访问成为安全漏洞(如著名的 Heartbleed 漏洞),go 编译器在每次读取之前插入检查。你可以在生成的汇编中查看它(查找 runtime.panic)。
编译的代码看起来像我们写了这样的东西:
|
|
在像这样的频繁调用循环(hot loop)中,即使是现代的分支预测,每次迭代的额外分支也会增加相当大的性能损失。这在我们的例子中尤其明显,因为插入的跳转限制了我们可以利用流水线的程度。
如果我们可以告诉编译器这些读取永远不会越界,它就不会插入这些运行时检查。这种技术被称为“边界检查消除”,相同的模式也适用于Go之外的语言。
理论上,我们应该能够在循环之外做所有的检查,编译器就能够确定所有的切片索引都是安全的。然而,我找不到正确的检查组合来说服编译器我所做的是安全的。我最终选择了断言长度相等的组合,并将所有的边界检查移到循环的顶部。这足以接近无边界检查版本的速度。
|
|
这个边界检查的最小化使我们的性能提高了 9%。但是始终未将检查降到零,没有什么值得一提的。
这个技术对于内存安全的编程语言来说是非常有用的,比如Rust。
一个问题抛给读者: 为什么我们要像a[i:i+4:i+4]
这样切片,而不是只是a[i:i+4]
?
目前我们已经提高了单核的搜索的吞吐率50%以上,但现在我们遇到了一个新的瓶颈:内存使用。我们的向量是1536维的。用4字节的元素,这就是每个向量6KiB,我们每GiB代码生成大约一百万个向量。这很快就积累起来了。我们有一些客户带着一些大型的monorepo
来找我们,我们想减少我们的内存使用,这样我们就可以更便宜地支持这些大型代码库。
一个可能的缓解措施是将向量移动到磁盘上,但是在搜索时从磁盘加载它们可能会增加显著的延迟,特别是在慢速磁盘上。相反,我们选择用int8量化我们的向量。
有很多方式可以压缩向量,但我们将讨论整数量化,这是相对简单但有效的。这个想法是通过将4字节的float32
向量元素转换为1字节的int8
来减少精度。
我不会深入讨论我们如何在float32
和int8
之间进行转换,因为这是一个相当深奥的话题,但可以说我们的函数现在看起来像下面这样:
|
|
这个改变导致内存使用量减少了4倍,但牺牲了一些准确性(我们进行了仔细的测量,但这与本博客文章无关)。
不幸的是,这个改变导致我们的性能下降了。查看产生的汇编代码(go tool compile -S
),我们可以看到一些int8
到int32
转换的指令,这可能解释了差异。我没有深入研究,因为我们在下一节中的所有性能改进都变得无关紧要了。
到目前为止,速度提升还不错,但对于我们最大的客户来说,还不够。所以我们开始尝试一些更激进的方法。
我总是喜欢找借口来玩SIMD。而这个问题似乎正好对症下药。
对于还不熟悉SIMD的同学来说,SIMD代表“单指令多数据”(Single Instruction Multiple Data
)。就像它说的那样,它允许你用一条指令在一堆数据上运行一个操作。举个例子,要对两个int32
向量逐元素相加,我们可以用ADD
指令一个一个地加起来,或者我们可以用VPADDD
指令一次加上64对,延迟相同(取决于架构)。
但是我们还是有点问题。Go不像C或Rust那样暴露SIMD内部函数。我们有两个选择:用C写,然后用Cgo,或者用Go的汇编器手写。我尽量避免使用Cgo,因为有很多原因,这些原因都不是根本原因,但其中一个原因是Cgo会带来性能损失,而这个片段的性能是至关重要的。此外,用汇编写一些东西听起来很有趣,所以我就这么做了。
我想要这个这个算法可以输出到其他编程语言,所以我限制自己只使用AVX2指令,这些指令在大多数x86_64服务器CPU上都支持。我们可以使用运行时进行检测,在纯Go中回退到一个更慢的选项。
|
|
这个实现的核心循环依赖于三条主要指令:
int8
加载到一个int16
向量中int16
向量逐个元素相乘,然后将相邻的两对模糊堆叠相加,生成一个 int32
向量。VPMADDWD
在这里是真正的主力军。通过将乘法和加法步骤合并为一个步骤,它不仅节省了指令,还帮助我们避免了溢出问题,同时将结果扩展为 int32
。
让我们看看这给我们带来了什么。
哇,这是我们之前最好表现的 530% 的增加!SIMD 胜利了 🚀。
现在,情况并非一帆风顺。在 Go 中手写汇编是有点奇怪的。它使用自定义的汇编器,这意味着它的汇编语言看起来与您通常在网上找到的汇编片段相比,会有略微不同而令人困惑。它有一些奇怪的怪癖,比如改变指令操作数的顺序或者使用不同的指令名称。在 Go 汇编器中,有些指令甚至没有名称,只能通过它们的二进制编码来使用。不得不说一句:我发现 sourcegraph.com 对于查找 Go 汇编示例非常有价值,可以供参考。
话虽如此,与 Cgo 相比,还是有一些不错的好处。调试仍然很好用,汇编可以逐步执行,并且可以使用 delve 检查寄存器。没有额外的构建步骤(不需要设置 C 工具链)。很容易设置一个纯 Go 的备用方案,所以跨编译仍然有效。常见问题被 go vet 捕捉到。
以前,我们限制自己只使用 AVX2
,但如果不这样呢?AVX-512
的 VNNI
扩展添加了 VPDPBUSD
指令,该指令计算 int8
向量而不是 int16
的点积。这意味着我们可以在单个指令中处理四倍的元素,因为我们不必先转换为 int16,并且我们的向量宽度在 AVX-512 中加倍!
唯一的问题是该指令要求一个向量是有符号字节,另一个向量是无符号字节。而我们的两个向量都是有符号的。我们可以借鉴英特尔开发者指南中的技巧来解决这个问题。给定两个 int8 元素 an
和 bn
,我们进行逐元素计算如下:an * (bn + 128) - an * 128
。an * 128
项是将 128
加到 bn
以将其提升到 u8
范围的超出部分。我们单独跟踪这部分并在最后进行减法。该表达式中的每个操作都可以进行向量化处理。
|
|
这种实现又带来了另外 21% 的改进。真不赖!
好吧,我对吞吐量增加 9.3 倍和内存使用量减少 4 倍感到非常满意,所以我可能会适可而止了。
现实生活中的答案可能是“使用索引”。有大量优秀的工作致力于使最近邻居搜索更快,并且有许多内置向量DB使其部署相当简单。
然而,如果你想要一些有趣的思考,我的一位同事在 GPU 实现的点积。
一些有价值的资料
|
|
这个大家都已经知道了,其实对应的提案中还有一个隐藏的功能,就是可以 range 一个函数,比如下面的代码(摘自官方代码库internal/trace/v2/event.go):
|
|
就少有介绍了。
本文尝试介绍它,让读者先了解一下,它在Go 1.22 中是一个实验性的功能,还不确定未来在哪个版本中会被正式支持。
官方wiki中也有一篇介绍: Rangefunc Experiment,类似问答的形式,也是必读的知识库。
这个功能去年Russ Cox发起讨论(#56413), 并建立一个提案(#61405),大家讨论都很激烈啊,几百次的讨论,所以我也不准备介绍前因后果了,直接了当的说结论。
先前, for-range
所能遍历(迭代)的类型很有限,只能是slice、数组、map、字符串、channel等。
现在,除了上面的五种类型,还可以是整数和三种三种函数。
当然for x := range n { ... }
等价于for x := T(0); x < n; x++ { ... }
, 其中T是n的类型。这个大家都知道了。
三个函数可能大家不是很了解,很正常,目前这只是一个实验性的功能。当然range的类型如下:
Range 表达式 | 第一个值 | 第二个值 |
---|---|---|
array or slice a [n]E, *[n]E, or []E | index i int | a[i] E |
string s string type | index i int | see below rune |
map m map[K]V | key k K | m[k] V |
channel c chan E, <-chan E | element e E | |
integer n integer type | index i int | |
function, 0 values f func(func()bool) bool | ||
function, 1 value f func(func(V)bool) bool | value v V | |
function, 2 values f func(func(K, V)bool) bool | key k K | v V |
本文介绍的就是后三种形式
假设f
是一个这样的函数:func(func()bool) bool
, 那么for x := range f { ... }
类似于f(func(x T1, y T2) bool { ... })
,其中for循环移动到方法体中了。yield
的bool返回值指示是否还要继续遍历。
对于这样一个f
,下面的格式都可以:
|
|
下面是一个例子:
|
|
运行可以看到结果符合预期,我们遍历了26个小写字母,注意range的数据类型是我们的函数:
这里,fn这个函数没有返回值,其实也可以有bool返回值,有bool返回值就可以组合多个range函数,可以容易写出复杂且难以维护的代码,减少自己失业的可能。
这里的yield
函数接收两个参数,第一个是int
类型,第二个是byte
类型,返回值是bool
类型,这个yield
函数的返回值决定了是否继续遍历。当然这里我们可以写泛型的程序,这里为了简单,就不写了。
下面是一个f
是func(func(V)bool) bool
的例子:
|
|
当然yield函数也可以没有参数,比如func(func()bool) bool
,下面这个例子就是无参数的形式,输出结果是26。
|
|
如果不使用for-range 函数的形式,我们可以进行改写,比如两个参数的列子:
|
|
注意yield
参数名称不是一个关键字,它只是一个普通的参数名称,可以随便取名字,但是为了模仿和其它语言中的generator
,使用了yield
这样一个名称,以至于代码更加易读。
看起来这个功能就是一个语法糖, 代码rangefunc/rewrite将range-over-func代码写成非range-over-func代码的形式。
标准库中就有 archive/tar.Reader.Next
, bufio.Reader.ReadByte
, bufio.Scanner.Scan
, container/ring.Ring.Do
, database/sql.Rows
, expvar.Do
, flag.Visit
, go/token.FileSet.Iterate
, path/filepath.Walk
, go/token.FileSet.Iterate
, runtime.Frames.Next
和sync.Map.Range
等各种遍历的函数,所以如果有一种统一的格式更好。
第三方库中有更多的类似代码。
虽然这个功能还没有正式支持,但是我看到有些库摩拳擦掌准备使用了,而sqlrange更进一步,已经支持了。
当然你使用它必须下载Go 1.22或者gotip, 并且设置export GOEXPERIMENT=rangefunc
。
它提供了Query
和Exec
可遍历函数。比如Query
从一个表中查询Point
数据:
|
|
遍历查询和ORM一气呵成。这里的资源管理是自动的,底层的*sql.Rows
遍历完会自动关闭。
|
|
这个大家都已经知道了,其实对应的提案中还有一个隐藏的功能,就是可以 range 一个函数,比如下面的代码(摘自官方代码库internal/trace/v2/event.go):
|
|
就少有介绍了。
本文尝试介绍它,让读者先了解一下,它在Go 1.22 中是一个实验性的功能,还不确定未来在哪个版本中会被正式支持。
官方wiki中也有一篇介绍: Rangefunc Experiment,类似问答的形式,也是必读的知识库。
]]>
首先,对核心网络结构进行了分析和重组。这项工作一直围绕着优化缓存行的使用和添加保护措施,以确保未来的更改不会倒退。反过来,这种对核心网络结构的优化导致许多并发连接的 TCP 性能提高了 40% 或更多!
Google 的 Coco Li 解释了他们对网络代码的 cachline 优化工作:
当前,网络栈中的变量密集结构按时间顺序、逻辑顺序组织,有时按缓存行访问组织。这个补丁系列试图重新组织核心网络栈变量,以在数据传输阶段最小化缓存行消耗。具体来说,我们研究了TCP/IP栈和TCP中的快速路径定义。
出于文档目的,我们还为我们考虑的每个核心数据结构添加了新的文件,尽管由于现有缓存行跨度的数量,并非所有在快速路径上都已被修改。在文档中,我们记录了我们在快速路径上识别的所有变量及原因。我们还希望在未来添加/修改变量时,可以参考并相应地更新文档,以反映最新变量组织。测试:我们的测试使用
neper tcp_rr
测试tcp流量。测试具有$cpu
数量的线程和可变数量的流量(见下文)。测试在6.5-rc1上运行 效率计算为cpu秒数/吞吐量(一个tcp_rr往返行程)。下面的结果显示在应用补丁系列之前和之后的效率delta。
|
|
|
|
对于Intel的服务器,优化不是那么明显,但是对于对于AMD EPYC服务器而言,这是一个天使般的改进,它们的网络性能提高了 40% 左右,这是一个巨大的提升。
相关的优化补丁可以查看Analyze and Reorganize core Networking Structs to optimize cacheline consumption, 主要由谷歌的Coco Li提交。
tcp的优化已经实现了,udp的优化还会远吗?
]]>本文还提供了一个类似Java的java.util.concurrent.Exchanger的Go并发原语,它可以用来在两个goroutine之间交换数据,快速实现双缓冲的模式。 这个并发原语可以在github.com/smallnest/exp/sync/Exchanger找到。
双缓冲(double buffering)设计方式虽然在一些领域中被广泛的应用,但是我还没有看到它在并发模式中专门列出了,或者专门列为一种模式。这里我们不妨把它称之为双缓存模式。
这是一种在I/O处理领域广泛使用的用来提速的编程技术,它使用两个缓冲区来加速计算机,该计算机可以重叠 I/O 和处理。一个缓冲区中的数据正在处理,而下一组数据被读入另一个缓冲区。
在流媒体应用程序中,一个缓冲区中的数据被发送到声音或图形卡,而另一个缓冲区则被来自源(Internet、本地服务器等)的更多数据填充。
当视频显示在屏幕上时,一个缓冲区中的数据被填充,而另一个缓冲区中的数据正在显示。当在缓冲区之间移动数据的功能是在硬件电路中实现的,而不是由软件执行时,全动态视频的速度会加快,不但速度被加快,而且可以减少黑屏闪烁的可能。
在这个模式中,两个goroutine并发的执行,一个goroutine使用一个buffer进行写(不妨称为buffer1),而另一个goroutine使用另一个buffer进行读(不妨称为buffer2)。如图所示。
当左边的writer写满它当前使用的buffer1后,它申请和右边的goroutine的buffer2进行交换,这会出现两种情况:
同样右边的goroutine也是同样的处理,当它读完buffer2后,它会申请和左边的goroutine的buffer1进行交换,这会出现两种情况:
这样两个goroutine就可以并发的执行,而不用等待对方的读写操作。这样可以提高并发处理的效率。
不仅仅如此, double buffering其实可以应用于更多的场景, 不仅仅是buffer的场景,如Java的垃圾回收机制中,HotSpot JVM把年轻代分为了三部分:1个Eden区和2个Survivor区(分别叫from和to,或者s0和s1),在GC开始的时候,对象只会存在于Eden区和名为“From”的Survivor区,Survivor区“To”是空的。紧接着进行GC,Eden区中所有存活的对象都会被复制到“To”,而在“From”区中,仍存活的对象会根据他们的年龄值来决定去向。年龄达到一定值的对象会被移动到年老代中,没有达到阈值的对象会被复制到“To”区域。经过这次GC后,Eden区和From区已经被清空。这个时候,“From”和“To”会交换(exchange)他们的角色,也就是新的“To”就是上次GC前的“From”,新的“From”就是上次GC前的“To”。不管怎样,都会保证名为To的Survivor区域是空的。Minor GC会一直重复这样的过程,直到“To”区被填满,“To”区被填满之后,会将所有对象移动到年老代中。
既然有这样的场景,有这样的需求,所以我们需要针对这样场景的一个同步原语。Java给我们做了一个很好的师范,接下来我们使用实现相应的Go,但是我们的实现和Java的实现完全不同,我们要基于Go既有的同步原语来实现。
基于Java实现的Exchanger的功能,我们也实现一个Exchanger
, 我们期望它的功能如下:
Exchange
方法两次Exchange
方法的完成,一定happens after另一个goroutine调用Exchange
方法的开始。如果你非常熟悉Go的各种同步原语,你可以很快的组合出这样一个同步原语。如果你还不是那么熟悉,建议你阅读《深入理解Go并发编程》这本书,京东有售。
下面是一个简单的实现,代码在Exchanger。
我们只用left
、right
指代这两个goroutine, goroutine是Go语言中的并发单元,我们期望的就是这两个goroutine发生关系。
为了跟踪这两个goroutine,我们需要使用goroutine id来标记这两个goroutine,这样避免了第三者插入。
|
|
你必须使用 NewExchanger
创建一个Exchanger
,它会返回一个Exchanger
的指针。
初始化的时候我们把left和right的id都设置为-1,表示还没有goroutine使用它们,并且不会和所有的goroutine的id冲突。
同时我们创建两个channel,一个用来左边的goroutine写,右边的goroutine读,另一个用来右边的goroutine写,左边的goroutine读。channel的buffer设置为1,这样可以避免死锁。
|
|
Exchange
方法是核心方法,它用来交换数据,它的实现如下:
|
|
当一个goroutine调用的时候,首先我们尝试把它设置为left
,如果成功,那么它就是left
。
如果不成功,我们就判断它是不是先前已经是left
,如果是,那么它就是left
。
如果先前,或者此时left
已经被另一个goroutine占用了,它还有机会成为right
,同样的逻辑检查和设置right
。
如果既不是left
也不是right
,那么就是第三者插入了,我们需要panic,因为我们不希望第三者插足。
如果它是left
,那么它就会把数据发送到right
,然后等待right
发送数据过来。
如果它是right
,那么它就会把数据发送到left
,然后等待left
发送数据过来。
这样就实现了数据的交换。
我们使用一个简单的双缓冲例子来说明如何使用Exchanger
,我们创建两个goroutine,一个goroutine负责写,另一个goroutine负责读,它们之间通过Exchanger
来交换数据。
|
|
在这个例子中 g1
负责写,每个buffer的容量是1024,写满就交给另外一个读g2,并从读g2中交换过来一个空的buffer继续写。
交换10次之后,两个goroutine都退出了,我们检查写入的数据和读取的数据是否一致,如果一致,那么就说明我们的Exchanger
实现是正确的。
文本介绍了一种类似Java的Exchanger
的同步原语的实现,这个同步原语可以在双缓冲的场景中使用,提高并发处理的性能。
本文还提供了一个类似Java的java.util.concurrent.Exchanger的Go并发原语,它可以用来在两个goroutine之间交换数据,快速实现双缓冲的模式。 这个并发原语可以在github.com/smallnest/exp/sync/Exchanger找到。
]]>在runtime/runtime2.go 定义了一个互斥锁,它的定义如下:
|
|
它可是运行时中的大红人了,在很多数据结构中都被广泛的使用,凡事涉及到并发访问的地方都会用到它,你在runtime2.go
文件中就能看到多处使用它的地方,因为很多地方都在使用它,我就不一一列举了在runtime这个文件夹中搜mutex
这个关键子就都搜出来了。
举一个大家常用来底层分析的数据结构channel
为例,channel的数据结构定义如下:
|
|
最后哪个字段lock mutex
就是使用的这个互斥锁。因为一个通道在发送和接收的时候都会涉及到对通道的修改,在多发送者或者接收者情况下,需要使用互斥锁来保护。
这个互斥锁的使用需要调用几个函数。
lockInit(&c.lock, lockRankHchan)
, 它将lock初始化(lockInit)时设置锁的等级(rank)。如果不明确去初始化一个锁,那么可以在调用lock自身的时候通过lockWithRank指定这个锁的等级。这个等级在启用GOEXPERIMENT=staticlockranking
用来加强锁的静态分析。lock(&c.lock)
unlock(&c.lock)
我在Go运行时中的 Mutex中详细介绍了它,这里就不再赘述了。
运行时中还实现了读写锁rwmutex。
这个读写锁完全是从sync.RWMutex
中拷贝过来的,只是将sync.RWMutex
中的sync
包替换成了runtime
包,因为sync
包依赖了runtime
包,所以不能直接使用。
你看它的数据结构定义和sync.RWMutex
几乎是一样的:
|
|
mutex
和rwmutex
会直接阻塞M
。
在其它编程语言中,会直接提供park
和unpark
的功能,比如rust,提供对并发单元的更底层的控制。
park
就是停止一会,很形象,就是暂时让并发单元阻塞,不再参与调度,直到unpark
它,它才会重新参与调度。
Go运行时并没有直接提供park
和unpark
的功能,它提供了gopark
和goready
的功能,它们的实现在runtime/proc.go。
gopark
会将goroutine放到等待队列中,从调度器的运行队列中移出去,等待被唤醒。goready
会将goroutine放到可运行队列中,加入到调度器的运行队列,等待被调度。
note
实现一次性的通知机制。
note
的数据结构如下:
|
|
可以使用notesleep
和notewakeup
进行休眠和唤醒。
就像mutex
一样,notesleep
会阻塞M
,notewakeup
会唤醒一个M
,并且不会重新调度G
和P
,而notetsleepg
就像一个阻塞的系统调用一样,允许P
选择另外一个G
运行。noteclear
用来重置note
总结一下, 上面几种同步原语阻塞的角色如下:
阻塞角色 | |||
---|---|---|---|
同步原语 | G | M | P |
mutex/rwmutex | Y | Y | Y |
note | Y | Y | Y/N |
park | Y | N | N |
"filelock"(文件锁)通常是指在计算机系统中使用的一种机制,用于确保对文件的独占性访问,以防止多个进程或线程同时修改文件而导致数据不一致或损坏。
一些应用程序经常利用文件锁,来控制只有一个实例在运行,在linux环境下非常常见,比如mysql等。
在不同的操作系统和编程语言中,文件锁的实现方式可能会有所不同。一般而言,文件锁可以分为两种主要类型:
文件锁的代码在cmd/go/internal/lockedfile中,我们以Linux为例,看看它的实现:
|
|
可以看到它实际是调用系统调用syscall.Flock
实现的。
这不属于运行时内定义的同步原语,但是它给我们提供了一个实现文件锁的思路,它甚至还封装了一个Mutex
供我们使用。如果有类似的需求,我们可以参考它的实现。
不太清楚Go为啥不在运行时或者标准库sync中实现信号量,而是在扩展包中去实现,信号量可以说是一个非常广泛使用的同步原语了。
虽然没有在运行时中没有明确实现,但是运行时中的runtime/sema.go提供了与信号量相近功能,而且sync.Mutex
严重依赖它。
这个实现旨在提供一个可以在其他同步原语争用的情况下使用的睡眠和唤醒原语,因此,它的目标与Linux的futex相同,但语义要简单得多。
Go团队说你不要将这些视为信号量,而是将它们视为一种实现睡眠和唤醒的方式,以确保每个睡眠都与单个唤醒配对,
这是有历史原因,这些从贝尔实验室出来的大佬,对于先前他们在Plan 9中的一些想法一脉相承的继承下来,这个设计可以参见 Mullender 和 Cox 的Plan 9中的信号量。
比如sync.Mutex
睡眠和唤醒的函数其实就是这里实现的:
|
|
atomic 提供原子操作,独立于sync/atomic
,仅供运行时使用。
在大多数平台上,编译器能够识别此包中定义的函数,并用平台特定的内部函数替换它们。在其他平台上,提供了通用的实现。
除非另有说明,在此包中定义的操作在处理它们所操作的值时对线程是有序一致的(sequentially consistent)。更具体地说,在一个线程上按特定顺序发生的操作,将始终被另一个线程观察到以完全相同的顺序发生。
因为和特定的CPU架构有关,它的实现针对不同的CPU架构,由不同的指令实现而成,而且基本使用汇编实现,比如AMD64下的Cas实现,使用了LOCK
+ CMPXCHGL
指令:
|
|
其实sync/atomic
下的实现,也是调用这里的实现,否则维护两套代码就太麻烦了,而且可能出现不一致的现象。你看sync/atomic/asm.s
:
|
|
它也是调用untime∕internal∕atomic
下对应的函数。
singleflight
特别适合大并发情况下许多请求做同一件事情的场景,这个时候只处理一个请求就可以了,其它请求等待那一个请求的结果,这样对下游的压力大大减少,比如在读取cache的时候。
因为它在特定场景下很有用,Go的扩展库中也同样实现了它。
它没有定义在运行时中,而是定义在internal/singleflight中。
比如在包net
中,我们查找一台主机的IP地址时,如果并发的请求,对资源是很大的浪费,这个时候我们只让一个请求处理就好了:
|
|
这篇文章就是就是利用atomic中的并发原语和条件变量,组合出类似C++ 20规范中atomic类型的wait/notify_one/notify_all的功能。
在C++ 20规范中,为atomic类型增加了wait/notify_one/notify_all的功能,这样就可以实现类似Java中的wait/notify/notifyAll的功能.
这三个方法类似于Go中的Cond(条件变量)的Wait/Signal/Broadcast方法。
c++也有条件变量,但是和Go的Cond类似,条件变量需要和mutex一起使用,而atomic类型的wait/notify_one/notify_all不需要和mutex一起使用的。
注意wait这个函数,
|
|
它的行为就像重复下面的操作一样:
this->load(order)
和old
的值notify_one()
或者 notify_all()
唤醒,或者线程被虚假的解锁这个函数保证返回时原子值被改变了,不管它是被唤醒的还是使用底层技术以虚假方式取消阻塞。
一个例子:
|
|
这个程序创建了16个异步任务,每个任务模拟了一些工作,然后通过原子操作更新已完成任务数量和未完成任务数量。主线程等待所有任务完成后输出已完成任务的数量。
注意: 由于 ABA 问题,原子值瞬态变化老到另一个值,然后返回到老的值,这个变化可能会被监听者锁遗漏,被Wait方法阻塞的线程无法解锁。
rust也有人提出了这样的需求:Is there a wait() and notify() for atomics?
大部分场景下,我们使用C++的std::condition_variable
或者Go语言中的sync.Cond
就可以了。
比如使用Go语言中的条件变量,我们可以将上面的例子改造成下面的代码:
|
|
可以看到,我们使用atmoic
的类型,加上Cond
(包括Mutex
),可以实现变量更改了,并且达到某个条件时,通知等待者的功能。
针对这种使用atmoic
的场景,我们是不是可以把atomic
+ Cond
封装成一个新的类型,这样就可以更方便的使用了。
一旦封装起来,就像C++ 20这样做的一样,为atomic类型增加了一个通知的“翅膀”,在条件(配置)监控、消息等待、事件通知的场景中,可以更方便的使用。
接下来就是我做的一个尝试。
相关的代码可以在github.com/smallnest/exp/sync/atomicx上找到。
不像Rust、Scala这样的语言,Go语言表达能力还不是那么丰富,所以我们无法在原有的atomic.XXX类型上增加wait/notify_one/notify_all的方法,只能创建一个新的类型,然后在这个类型上增加这三个方法。
我们还是沿用Go语言的Wait/Signal/Broadcast
的命名方式,这样使用者就不会感到陌生,而不是C++的wait/notify_one/notify_all
命名方式。
你可以看到,标准库atomic包下针对不同的基本类型,有对应的atomic.XXX类型,比如atomic.Bool
、atomic.Int32
、atomic.Uint64
等等,所以我们也沿用这种方式,创建了atomicx.Bool
、atomicx.Int32
、atomicx.Uint64
等等。
你可以思考一下,为什么Go标准库不写成泛型的方式,,只提供一个
atomicx.Atomic[T]
类型,这样就可以避免创建这么多的类型了。
我们以atomicx.Int32
为例,看看它的实现。
这里我们采用组合的方式,将atomic.Int32
和sync.Cond
组合在一起,然后在这个组合类型上增加Wait/Signal/Broadcast
方法。
|
|
我们采用Go标准库sync包中的各种同步原语的风格,声明的时候默认零值,不需要new(XXX)
方式显式创建,这样使用起来更方便。
这样就带来一个问题,怎么初始化sync.Cond
字段呢?它是需要NewCond
函数创建的,传入一个Locker
。
这里我们使用一个技巧,惰式初始化,需要使用它的时候,先请求锁,然后在检查它是否初始化了,如果没有初始化,就初始化它。
Wait
方法就是不断的Load
这个原子值,和初始值进行比较,如果相等,就阻塞当前线程,直到被Signal
或者Broadcast
唤醒,当值不一致时,返回。
Signal
和Broadcast
方法就是调用sync.Cond
的Signal
和Broadcast
方法。
这是一个比较简单的通过组合的方式实现C++ 20中atomic类型的wait/notify_one/notify_all的功能的例子。
相信将Cond和Mutex的实现的代码拆解出来,再加上atomic.XXX的实现,你可能会实现性能更高的同样功能的同步原语,那样代码可能就变得复杂反而不如这种组合的方式更容易维护。
既然我们实现了一个封装类型atomicx.Bool
,我们就用起来。
那么我们就可以把下面三个字段使用一个var completed atomicx.Bool
来替换了。
|
|
如果条件满足,我们可以把completed
设置为true,并且通知一个等待的goroutine。
等待的goroutine的代码也可以简化,只使用一条completed.Wait()
就行了,不需要加锁和For循环。
|
|
注意completed.Wait()
一定要在completed.Store(true)
之前,否则主goroutine可能永远被阻塞。
如果一个原子量快速的从A变成B,然后又快速的从B变成A,那么一个等待者可能会错过这个变化,从而导致它永远阻塞。
为了解决这个问题,我们可以在原子量的值的基础上增加一个版本号,每次变化的时候,版本号也会变化,这样等待者就可以检查版本号是否变化了,如果变化了,就不会阻塞。
下面就是定义了一个要原子操作的类型,每次做更改的时候:
|
|
这样即使completed.Value的值从true变成false,但是Version的值也会变化,这样等待者就不会错过这个变化了:
|
|
这篇文章就是就是利用atomic中的并发原语和条件变量,组合出类似C++ 20规范中atomic类型的wait/notify_one/notify_all的功能。
]]>std::thread
是在C++11中引入的,它表示一个可执行的线程。线程允许多个函数并发执行。
线程在关联的线程对象构造完成后立即开始执行(受操作系统调度延迟影响),从作为构造函数参数提供的顶级函数开始。顶级函数的返回值会被忽略,如果它通过抛出异常终止,std::terminate 会被调用。顶级函数可以通过 std::promise 或通过修改共享变量(可能需要同步,参见 std::mutex 和 std::atomic)向调用者传达其返回值或异常。
std::thread 对象也可能处于不表示任何线程的状态(在默认构造、移动构造、分离或加入之后),而且执行线程可能不与任何线程对象关联(在分离之后)。
没有两个 std::thread 对象可以表示同一个执行线程;std::thread 不可复制构造或复制赋值,尽管它是可移动构造和可移动赋值的。
你需要手动管理线程的生命周期,包括启动和加入(或分离)线程。如果你忘记在一个std::thread
对象销毁之前加入(join
)或分离(detach
)它的线程,程序将会终止(因为std::thread
的析构函数会调用std::terminate
)。
|
|
std::jthread
是在C++20中引入的,它提供了一些改进和附加功能,相比于std::thread
,使得线程管理变得更加容易和安全。
它在std::thread的基础上增加了自动的线程加入功能。std::jthread的一个关键特性是它的析构函数会自动请求线程停止(如果支持的话)并等待线程完成,从而减少了程序员需要手动管理线程生命周期的需求。
此外,std::jthread支持协作式中断,它提供了一种机制,使得线程可以被请求停止执行。这是通过传递一个std::stop_token来实现的,线程函数可以定期检查这个stop_token来决定是否应该停止执行。
|
|
在上面的例子中,std::jthread
的析构函数会自动调用request_stop
来请求线程函数停止,然后等待线程完成。这使得使用std::jthread
比std::thread
更加安全和方便,因为它消除了忘记加入或分离线程时可能出现的问题。
总结来说,std::jthread
在std::thread
的基础上提供了自动加入和协作式中断的功能,从而简化了线程的管理。如果你使用的是C++20或更高版本,优先考虑使用std::jthread
。
std::atomic
是一个模板类,提供了一种机制来安全地在多线程环境中操作共享数据,而不需要使用互斥锁。std::atomic
类型保证了基本的原子操作,比如读取、写入、递增和递减等,都是原子性的,也就是说在一个操作执行完毕前,不会被其他线程打断。
原子性意味着当一个线程正在执行原子操作时,没有其他线程可以同时执行任何其他对同一数据的原子操作。此外,C++11 引入的内存模型定义了原子操作的内存顺序(memory order),这是一个非常复杂的主题,决定了在不同线程中操作之间的可见性和排序。我在《并发编程顶峰对决: Go vs Rust》讲了Rust的内存顺序模型,也提到了Rust的内存顺序模型和C++的内存顺序模型,这里就不赘述了,总之内存序包含下面几种类型,你应该正确且清晰的使用它们:
std::atomic
类型的对象可以通过调用成员函数load
和store
来读取和写入,也可以通过operator++
和operator--
来递增和递减。std::atomic
类型的对象还可以通过调用成员函数exchange
来交换值,通过调用成员函数compare_exchange_weak
和compare_exchange_strong
来比较和交换值。
wait
、notify
和notify_all
函数可以用来等待和通知其他线程,这些函数在C++20中引入。有点像条件变量。
通过std::atomic
类模板,我们可以创建原子类型的对象,比如std::atomic<int>
,std::atomic<bool>
,std::atomic<std::string>
等等。std::atomic
类模板还提供了一些特化版本,比如std::atomic_flag
,std::atomic_bool
,std::atomic_int
,std::atomic_uint
,std::atomic_llong
等等。
下面是一个计数器的例子:
|
|
在这个例子中,10个线程并发地递增一个std::atomic
std::atomic_flag
是 C++11 中引入的原子类型,它是最简单的原子类型,提供了一个布尔标志,可以用来进行简单的锁定操作。由于其简单性,std::atomic_flag
通常可以实现为一个非常高效的原子类型,因此它在实现自旋锁等低级同步原语时非常有用。
std::atomic_flag
保证是 lock-free
的,即不会引起调用线程的阻塞。这是 std::atomic_flag
相对于其他原子类型的一个独特优点,因为其他原子类型在一些平台上可能不是 lock-free
的。
std::atomic_flag 提供以下几个主要操作:
下面这个例子是检查 atomic_flag 是否已被设置:
|
|
std::atomic_ref
是 C++20 引入的一个模板类,它提供了对非原子类型的原子操作。这意味着你可以将 std::atomic_ref
对象绑定到非原子类型的引用上,并执行原子操作,而无需将该类型本身声明为原子类型。这在你需要对现有数据结构中的某个成员进行原子操作,而不想更改数据结构定义时非常有用。
std::atomic_ref
的特性
std::atomic_ref
通过引用传递给它的对象,并提供原子访问。std::atomic_ref
不拥有它所绑定的对象,故该对象的生命周期必须超过 std::atomic_ref 对象的生命周期。std::atomic_ref
实例之间共享同一个对象,但是要保证这些实例不会同时访问该对象。std::atomic_ref 的主要成员函数
以下是一个使用 std::atomic_ref 的简单示例,演示了如何对一个共享的 int 变量执行原子操作:
|
|
在这个例子中,我们创建了一个普通的 int 类型变量 shared_value
和一个 std::atomic_ref<int>
实例 atomic_ref
,后者引用了前者。然后我们启动了 10 个线程,每个线程都通过 atomic_ref
原子地对 shared_value
执行 100 次增加操作。在所有线程完成后,我们期望 shared_value
的最终值为 1000。
std::mutex
用于保护共享数据,避免多个线程同时访问导致的数据竞争和不一致性。当多个线程尝试同时修改同一数据时,std::mutex
提供了一种机制来确保只有一个线程能够访问数据,其余试图访问该数据的线程将被阻塞,直到拥有互斥锁的线程释放锁为止。
std::mutex
的主要操作
使用 std::mutex
保护共享数据的例子:
|
|
在这个例子中,100个线程尝试并发地增加一个共享计数器。没有互斥锁的情况下,多个线程可能同时读写同一内存位置,导致计数器的值不正确。通过使用 std::mutex,我们确保了每次只有一个线程能够增加计数器,从而保证了最终结果的正确性。
可以使用std::lock_guard
管理 std::mutex
的锁定和解锁,类似Rust:
|
|
除了基本的 std::mutex
,还提供了几种其他类型的锁,用于满足不同的同步需求。以下是一些常见的锁类型:
std::recursive_mutex
是一种特殊类型的互斥锁,它允许同一个线程多次对同一个互斥锁加锁(即递归锁定)。每次对 std::recursive_mutex
的成功锁定都必须由相应数量的解锁操作与之匹配。这适用于递归函数调用,其中函数可能会直接或间接地多次请求同一互斥锁。std::timed_mutex
是互斥锁的一个扩展,它提供了尝试锁定一段时间的功能。如果锁在指定时间内未被获取,则尝试锁定操作失败并返回。它提供了两个额外的成员函数:try_lock_for()
和 try_lock_until()
,分别用于指定等待锁定的时间长度和绝对时间点。std::recursive_timed_mutex
结合了 std::recursive_mutex
和 std::timed_mutex
的功能,允许一个线程对同一个互斥锁进行多次锁定,并提供了基于时间的锁定尝试。std::shared_mutex
是一个读写锁,它允许多个线程同时读取共享数据(共享锁定),但一次只允许一个线程写入(独占锁定)。它提供了 lock_shared()
和 unlock_shared()
来管理共享锁定,以及 lock()
和 unlock()
来管理独占锁定。std::shared_timed_mutex
结合了 std::shared_mutex
和 std::timed_mutex
的特性,提供了时间限制的读写锁。它允许多个线程在一段时间内尝试以共享或独占方式锁定互斥锁。还有一些辅助管理mutex的类:
std::lock_guardstd::lock_guard
是一个作用域锁,当创建它的对象时自动获取互斥锁,并在该对象的生命周期结束时自动释放互斥锁。std::lock_guard
不支持显式的解锁操作或条件等待。
std::unique_lockstd::unique_lock
是一个灵活的作用域锁,它提供了比 std::lock_guard
更多的功能,包括延迟锁定、时间限制的锁定尝试、递归锁定以及条件变量的支持。std::unique_lock
对象可以在其生命周期中多次锁定和解锁关联的互斥锁。
std::scoped_lock (C++17)std::scoped_lock
是 C++17 引入的一个作用域锁,它可以锁定一个或多个互斥锁,而无需担心死锁。它在内部使用了一个死锁避免算法(如锁的排序获取),确保在多个互斥锁的情况下不会发生死锁。
一个读写锁的例子:
|
|
在这个例子中,std::shared_mutex
被用作一个读写锁来保护共享数据。读者线程使用 std::shared_lock
获取共享锁,这允许多个读者线程同时读取数据。写者线程使用 std::unique_lock
获取独占锁,这确保了在写入数据时只有一个写者线程可以访问数据。
std::condition_variable
用于在多线程程序中进行线程间的通知和等待操作。它允许一个或多个线程在某些条件成立之前挂起(等待),直到另一个线程通知它们条件已经满足。std::condition_variable
通常与 std::mutex
(或 std::unique_lock
)一起使用,以保护共享数据并提供安全的同步机制。
主要方法:
下面是一个使用 std::condition_variable
的简单示例,演示了生产者-消费者问题:
|
|
在这个例子中,生产者线程生产产品并将其放入队列中,然后通过调用 cv.notify_one() 唤醒一个等待的消费者线程。消费者线程在队列为空时调用 cv.wait() 进入等待状态,等待生产者的通知。当生产者生产了一个产品后,消费者线程被唤醒,从队列中取出产品并消费它。
注意事项: (和Go的Cond类似)
std::condition_variable
时,应该总是和一个互斥锁一起使用,以避免竞争条件。wait()
、wait_for()
或 wait_until()
时,互斥锁必须已被锁定。这些函数会在开始等待时自动释放锁,并在线程被唤醒时重新获取锁。std::condition_variable
的 wait()
函数可能会出现"虚假唤醒",即在没有收到通知的情况下线程可能被唤醒。因此,通常需要在一个循环中使用 wait()
,并检查等待条件是否满足。notify_one()
和 notify_all()
不需要持有互斥锁,但通常会在更新共享数据并持有互斥锁后调用它们。std::condition_variable
只能与 std::unique_lock<std::mutex>
一起使用,不能直接与 std::mutex
一起使用。如果你需要和 std::mutex
一起使用条件变量,请使用 std::condition_variable_any
。在 C++20 之前,标准库没有提供信号量(semaphore)的实现,但是在 C++20 中引入了两种类型的信号量:std::counting_semaphore
和 std::binary_semaphore
。
std::counting_semaphore
是一种通用的同步原语,用于控制对有限数量资源的访问。它维护一个内部的计数器,表示可用资源的数量。计数器的值可以增加(通过 release()
函数)或减少(通过 acquire()
函数)。
信号量的主要操作包括:
std::counting_semaphore
的计数器值可以大于1,因此它可以用于多个资源的同步。例如,可以用它来实现连接池,限制同时运行的线程数量,等等。
std::binary_semaphore
是 std::counting_semaphore
的一个特例,其计数器值限定为最多1。这意味着它可以被看作是一个可以阻塞线程的布尔标志。
std::binary_semaphore
的行为类似于互斥锁(mutex
),但与互斥锁不同的是,std::binary_semaphore
不要求同一个线程执行 acquire()
和 release()
。这使得信号量可以用于线程间的通知和同步,而不仅仅是互斥。
下面是一个使用std::counting_semaphore
的例子:
|
|
std::barrier
也是 C++20 引入的一个同步原语,它允许一组线程相互等待,直到所有线程都达到某个同步点(称为屏障点或栅栏点),然后再继续执行。std::barrier
可以用于协调并行算法中的线程,确保所有线程都完成了某个阶段的工作,然后再一起进入下一个阶段。
std::barrier 的主要特性
std::barrier 的主要成员函数
以下是一个使用 std::barrier 的简单示例,演示了如何同步多个线程在屏障点上相互等待:
|
|
在这个例子中,我们创建了一个 std::barrier
对象 sync_point
,它配置为同步三个线程。每个线程都执行一些工作,然后调用 sync_point.arrive_and_wait()
来等待其他线程。一旦所有三个线程都到达屏障点(即都调用了 arrive_and_wait()
),它们将一起继续执行后面的代码。
std::barrier
是一种强大的同步工具,特别适用于需要分阶段执行的并行算法,确保在算法的每个阶段开始前,所有线程都已完成前一个阶段的工作。这有助于避免竞争条件,确保算法的正确执行。
std::latch
也是 C++20 引入的一个同步原语,它使一组线程可以等待直到一个给定数量的操作完成。它是一个一次性的屏障,一旦触发打开,就不能再重置或再次使用。std::latch
用于在多个线程之间同步操作,允许一个线程等待一个或多个线程完成某些操作。类似Java中的CountDownLatch。
std::latch 的主要特性
std::latch 的主要成员函数
以下是一个使用 std::latch 的简单示例,演示了如何同步多个线程完成初始化操作后,主线程才继续执行:
|
|
在这个例子中,std::latch
用于确保三个并发运行的初始化操作都完成了,主线程才开始执行后续的任务。每个初始化线程在完成初始化后调用 initialization_latch.count_down()
,这将减少 std::latch
的计数器。主线程调用 initialization_latch.wait()
来等待所有的初始化操作完成。一旦所有的初始化操作都调用了 count_down()
,std::latch
的计数器达到零,主线程将继续执行。
std::latch
是一个非常有用的同步工具,特别是在涉及一次性事件或需要多个线程完成启动步骤之后才能继续的场景中。它简化了在这些情况下的线程协调。
std::promise
和 std::future
是 C++11 引入的同步原语,它们提供了一种在线程之间传递值的机制,也可以用于线程之间的同步。std::async
是 C++11 引入的一个函数模板,用于异步执行一个函数或可调用对象,并返回一个 std::future
对象,以便在将来某个时间点获取该函数的结果。
std::promise
允许你在某个线程中存储一个值或异常,该值或异常可以在将来某个时刻被另一个线程检索。当创建 std::promise
对象时,它与一个 std::future
对象相关联,std::future
对象可用于访问 std::promise
中存储的值。
主要成员函数包括:
std::future
提供了一种访问异步操作结果的机制。它与 std::promise
对象相关联,用于获取通过 promise
设置的值或异常。
主要成员函数包括:
std::async
是一个函数模板,用于启动一个异步任务,它的返回类型是 std::future
,通过该 future
可以访问异步任务的结果。当调用 std::async
时,可以指定一个函数或可调用对象,以及传递给该函数的参数。std::async
可以指定启动策略,例如 std::launch::async
(在新线程中运行)或 std::launch::deferred
(延迟执行,直到调用 std::future::get()
或 wait()
)。
以下是一个使用 std::promise
和 std::future
的简单示例,模拟了一个异步计算任务:
|
|
在这个例子中,std::async
用于启动一个新线程来执行 compute
函数。通过返回的 std::future
对象,主线程可以在稍后获取异步计算的结果。如果在此期间尚未完成计算,调用 fut.get()
将会阻塞主线程。
std::promise
、std::future
和 std::async
提供了 C++ 中进行异步编程的基础设施,允许开发者在不同线程之间传递数据和同步操作,同时将并发复杂性降到最低。
http.ServeMux
中的模式匹配,先前这个功能是很多第三方的web框架或者router库实现的。
我们很有必要好好研究它,将来在实现HTTP API的时候可能优先使用它。
http.ServeMux
是一个HTTP请求多路复用器,它将每个传入请求的URL与已注册的模式列表进行匹配,并调用与最接近URL匹配的模式对应的处理程序。
比如:
|
|
和原来的HandleFunc
相比,第一个参数貌似有了不一样的变化,除了正常的path之外,还有HTTP POST Method,还有{id}
和{path...}
这样的变量,这就是Go 1.22中新增加的模式匹配。
通过PathValue
可以获取路径中的通配符匹配的值。
模式匹配用在注册handler的时候,如下:
|
|
其中pattern
模式的格式是这样子的:[METHOD ][HOST]/[PATH]
, 中括号代表这一项可以省略。
因此POST /items/create
、POST rpcx.io/items/create
、GET /items/123
、/items/123
都是合法的模式。
如果不设置HTTP Method如POST
,那么默认是匹配所有的HTTP Method。注意HTTP Method 之后有一个空格。
模式匹配GET
会匹配GET
和HEAD
,除此之外,其他的HTTP Method都是精确匹配。
如果未设置HOST,那么默认是匹配所有的HOST。否则,HOST必须完全匹配。
请求的路径中可以包含通配符,如"/b/{bucket}/o/{objectname...}"
。配符名称必须是有效的Go标识符。通配符必须是完整的路径段,例如/b_{bucket}
就不是一个合法的通配符。
/items/{id}
: 正常情况下一个通配符只匹配一个路径段,比如匹配/items/123
,但是不匹配/items/123/456
。/items/{apth...}
: 但是如果通配符后面跟着...
,那么它就会匹配多个路径段,比如/items/123
、/items/123/456
都会匹配这个模式。/items/{$}
: 以/
结尾的模式会匹配所有以它为前缀的路径,比如/items/
、/items/123
、/items/123/456
都会匹配这个模式。如果以/{$}
为后缀,那么表示严格匹配路径,不会匹配带后缀的路径,比如这个例子只会匹配/items/
,不会匹配/items/123
、/items/123/456
。在匹配过程中,模式路径和传入请求路径都会逐段解码。因此,例如,路径 /a%2Fb/100%25
被视为具有两个段,a/b
和 100%
。模式 /a%2fb/
与之匹配,但模式 /a/b/
则不匹配。
如果两个模式都可以匹配同一个路径咋办呢?比如/items/{id}
和/items/{path...}
都可以匹配/items/123
,那么谁优先呢?
/items/{id}
比/items/
更具体。rpcx.io/items/{id}
比/items/{id}
优先权更高。items/{id}
和/items/{index}
都没有HOST,所以会panic。/
的转发/images/
会导致ServeMux
把/images
重定向到/images/
除非你注册了/images
的handler。
ServeMux
还负责清理URL请求路径和Host标头,去除端口号,并将包含 .
或 ..
段或重复斜杠的任何请求重定向到等效、更清晰的URL。
http.ServeMux
中的模式匹配,先前这个功能是很多第三方的web框架或者router库实现的。
我们很有必要好好研究它,将来在实现HTTP API的时候可能优先使用它。
]]>math/rand
包。这个包的目标是提供一个更好的伪随机数生成器,它的 API 也更加简单易用。本文将介绍这个新的包的特性。Go 1.22 release notes 正在编写之中,大家可以关注这个网页以便全面了解Go 1.22的变化,前几天有Gopher制作了一个交互式运行新特性代码的网页,也非常好,在reddit上关注度很高。今天这篇文章只关注于于math/rand/v2
这个新的包。
其实大家对math/rand
不是那么满意。
2017年,#20661 中提到math/rand.Read
和crypto/rand.Read
相近,导致本来应该使用crypto/rand.Read
的地方使用了math/rand.Read
,导致了安全问题。
2017年,#21835 中 Rob Pike 提议在Go 2中使用PCG Source。
2018年,#26263 中 Josh Bleecher Snyder 提议对math/rand
进行彻底的重构。
2023年6月, Russ Cox基于先前的对math/rand
的吐槽,以及和Rob Pike的讨论,建立了一个讨论(#60751),准备新建一个包math/rand/v2
,重新设计和实现一个新的伪随机数的库讨论也很热烈,最后实现了一个提案#61716,这个提案最直接的动机是清理 math/rand
并解决其中许多悬而未决的问题,特别是使用过时生成器、缓慢的算法,以及与 crypto/rand.Read
的不幸冲突。
由于go module的支持版本v2、v3、...
, Go 1.22中将会有一个新的包math/rand/v2
,这个包将会是一个新的包,而不是math/rand
的升级版本。这个包的目标是提供一个更好的伪随机数生成器,它的 API 也更加简单易用,同时一些检查工具也能支持这个包,不会报错。
看样子,math/rand/v2
将会是第一个在标准库中建立v2
版本的包,如果大家能够接受,将来会有更多的包加入进来,比如sync/v2
、encoding/json/v2
等等。
math/rand/v2
API 以 math/rand
为起点,进行以下不兼容的更改:
1、 移除 Rand.Read
和顶层的 Read
。假装伪随机生成器是任意长字节序列的良好来源几乎总是错误的。math/rand
适用于模拟和非确定性算法,几乎从不需要字节序列。Read
是 math/rand
和 crypto/rand
之间唯一共享的 API 部分,代码应该基本上总是使用 crypto/rand.Read
。(math/rand.Read
和 crypto/rand.Read
存在问题,因为它们具有相同的签名; math/rand.Int
和 crypto/rand.Int
也都存在,但具有不同的签名,这意味着代码永远不会意外地将一个错认为是另一个。)
2、 移除 Source.Seed
、Rand.Seed
和顶层的 Seed
。顶层的 Seed
已在 Go 1.20 中废弃。Source.Seed
和 Rand.Seed
假定底层源可以由单个 int64
作为种子,这只对有限数量的源是真实的。具体的源实现可以提供具有适当签名的 Seed
方法,或者对于不能重新设置种子的生成器根本不提供;简单来说使用一个int64
作为种子没有普适性,不适合定义一个通用的接口。
注意,移除顶层 Seed
意味着顶层函数如 Int
将始终以随机方式而不是确定性方式生成。math/rand/v2
将不关注 math/rand
所关注的 randautoseed
GODEBUG 设置;顶层函数的自动设置哦随机种子是唯一的模式。这反过来意味着顶层函数使用的具体 PRNG 算法是未指定的,可以在发布之间更改而不破坏任何现有代码。
3、 将 Source
接口更改为具有单个 Uint64() uint64
方法,取代 Int63() int64
。后者过于拟合原始的 Mitchell & Reeds LFSR 生成器。现代生成器可以提供 uint64
。
4、 移除 Source64
,现在不再需要,因为 Source
提供了 Uint64
方法。
5、 在 Float32
和 Float64
中使用更直观的实现。以 Float64
为例,它最初使用 float64(r.Int63()) / (1<<63)
,但这存在问题,偶尔会四舍五入为 1.0
。我们尝试将其更改为 float64(r.Int63n(1<<53) / (1<<53)
,避免了四舍五入的问题。
6、 修复 ExpFloat64
和 NormFloat64
中的偏差问题。
7、 使用 Rand.Shuffle
实现 Rand.Perm
。
8、 将 Intn
、Int31
、Int31n
、Int63
、Int64n
重命名为 IntN
、Int32
、Int32N
、Int64
、Int64N
。原来的名称中的 31
和 63
是令人困惑的,而大写 N
在 Go 中作为名称的第二个“单词”更为习惯。
9、 添加 Uint32
、Uint32N
、Uint64
、Uint64N
、Uint
、UintN
,既作为顶层函数,也作为 Rand
的方法。
10、在 N
、IntN
、UintN
等中使用 Lemire 的算法。初步基准测试显示,与 v1 Int31n
相比,节省了 40%,与 v1 Int63n
相比,节省了 75%。
11、添加一个通用的顶层函数 N
,类似于 Int64N
或 Uint64N
,但适用于任何整数类型。特别是这允许使用 rand.N(1*time.Minute)
来获取范围在 [0, 1*time.Minute)
内的随机持续时间。
12、添加一个新的 Source
实现,PCG-DXSM
。PCG 是一个简单、高效的算法,具有良好的统计随机性质。DXSM 变体是作者专门为纠正原始 (PCG-XSLRR) 中的一种罕见、隐晦的缺陷而引入的,并且现在是 Numpy 中的默认生成器。
13、移除 Mitchell & Reeds LFSR 生成器和 NewSource。
14、添加一个新的 Source 实现,ChaCha8
。ChaCha8 是从 ChaCha8 流密码派生的具有强密码学随机性质的随机数生成器。它提供与 ChaCha8 加密等效的安全性。
15、在 math/rand/v2
和 math/rand
(未设置种子时)中使用每个 OS 线程的 ChaCha8 作为全局随机生成器。
注意,根据go module的定义,v2
只是版本号,新的包名还是叫做rand
。
rand
包实现了适用于模拟(simulation
)等任务的伪随机数生成器,但不应用于对安全性敏感的工作。
随机数由 Source
生成,通常包装在 Rand
中。这两种类型应该一次由单个 goroutine 使用:在多个 goroutine 之间共享需要某种形式的同步。
顶层函数,如 Float64
和 Int
,对于多个 goroutine 的并发使用是安全的。
该包的输出可能在设置种子的方式不同的情况下很容易可预测。对于适用于对安全性敏感的工作的随机数,请参阅 crypto/rand
包。
简单综述:所以你考虑到安全避免被人预测的场景下,还是要使用crypto/rand
包。 包级别的函数比如Int
是线程安全的,但是如果你自己生成一个Rand
对象,那么就要注意了,因为Rand
对象是非线程安全的。
|
|
针对int32
、int64
、uint32
、uint64
,分别有Xxxxx()
和XxxxxN()
两种函数,前者返回一个随机数,后者返回一个范围在[0,n)
的随机数。Float32
和Float64
返回范围在[0.0, 1.0)
的随机浮点数。IntN
返回一个范围在[0,n)
的随机数,数据类型是int
类型。N
是一个泛型的函数,返回一个范围在[0,n)
的随机数,底层数据是int类型的,特别适合time.Duration
这样的类型。
Perm
返回一个长度为n
的随机排列的int
数组。Shuffle
洗牌算法
NormFloat64
返回一个标准正态分布的随机数。ExpFloat64
返回一个指数分布的随机数。
ChaCha8
也是包级别的函数使用的伪随机数生成器。
|
|
PCG
是另外一种伪随机数生成器。
|
|
Zipf
是生成Zipf分布的伪随机数生成器。
|
|
相信后续还会有一些第三方的伪随机数生成器出现。
它们都实现了接口Source
,Source
接口只有一个方法Uint64()
:
|
|
所有的伪随机数生成器都可以包装成一个Rand
对象,Rand
对象是非线程安全的,所以要注意。
|
|
这和Rust中的实现模式类似。<
>第一版把它叫做伴型特性,第二版中不知道为什么把这一节去掉了。
Rust中的Rng
类似这里的Go的Source
,可以有多种实现生成器。Rust中的Rand
也类似这里Go的Rand
,基于Uint64() uint64
提供各种类型的随机数。
Rand
提供了各种便利的方法,这些方法其实和包级别的函数是一样的,只是它们是Rand
对象的方法而已:
|
|
math/rand
包。这个包的目标是提供一个更好的伪随机数生成器,它的 API 也更加简单易用。本文将介绍这个新的包的特性。当然我已经实现了一个batch库,你可以直接拿来用,本文主要介绍它的功能、使用方法以及设计原理和考量:github.com/smallnest/exp/chanx。
我们可以使用这个库的Batch
方法来批量读取数据,它的定义如下:
|
|
Context
,可以让调用者主动取消或者超时控制举一个例子:
|
|
这个例子一开始我们把10个数据写入到一个channel中,然后我们从channel中批量读取,每次读取5个,然后把这5个数据传递给一个函数来处理,我们可以看到,我们读取了两次,每次读取5个,总共读取了10个数据。
我们还可以使用FlatBatch
方法来批量读取批量数据,它的定义如下:
|
|
这个函数和Batch
类似,只不过它的channel中的数据是一个切片,每次从channel中读取到一个切片后,把这个切片中的数据展开放入到一批数据中,最后再传递给处理函数。所以它有Flat
和Batch
两个功能。
举一个例子:
|
|
在这个例子中,我们把10个切片写入到channel中,每个切片中有两个元素,然后我们从channel中批量读取并展开,放入到一个batch中,如果batch中的数据大于或等于5个,就把这5个数据传递给一个函数来处理,我们可以看到,我们读取了两次,每次读取5个,总共读取了10个数据。
想要从channel中批量读取数据,我们需要考虑以下几个问题:
我先举一个简单但是不太好的实现方式,我们在它的基础上做优化:
|
|
这个实现中我们使用了一个batch
变量来保存从channel中读取的数据,当batch
中的数据量达到batchSize
时,我们就把这个batch
传递给处理函数,然后清空batch
,继续读取数据。
这个实现的一个最大的问题就是,如果channel中没有数据,并且当前batch的数量还未达到预期, 我们就会一直等待,直到channel中有数据,或者channel被关闭,这样会导致消费者饥饿。
我们可以使用select
语句来解决这个问题,我们可以在select
语句中加入一个default
分支,当channel中没有数据的时候,就会执行default
分支以便在channel中没有数据的时候,我们能够把已读取到的数据也能交给函数fn去处理。
|
|
这个实现貌似解决了消费者饥饿的问题,但是也会带来一个新的问题,如果channel中总是没有数据,那么我们总是落入default
分支中,导致CPU空转,这个goroutine可能导致CPU占用100%, 这样也不行。
有些人会使用time.After
来解决这个问题,我们可以在select
语句中加入一个time.After
分支,当channel中没有数据的时候,就会执行time.After
分支,这样我们就可以在channel中没有数据的时候,等待一段时间,如果还是没有数据,就把已读取到的数据也能交给函数fn去处理。
|
|
这样貌似解决了CPU空转的问题,如果你测试这个实现,生产者在生产数据很慢的时候,程序的CPU的确不会占用100%。
但是正如有经验的Gopher意识到的那样,这个实现还是有问题的,如果生产者生产数据的速度很快,而消费者处理数据的速度很慢,那么我们就会产生大量的Timer
,这些Timer不能及时的被回收,可能导致大量的内存占用,而且如果有大量的Timer,也会导致Go运行时处理Timer的性能。
这里我提出一个新的解决办法,在这个库中实现了,我们不应该使用time.After
,因为time.After
既带来了性能的问题,还可能导致它在休眠的时候不能及时读取channel中的数据,导致业务时延增加。
最终的实现如下:
|
|
这个实现的巧妙之处在于default
出来。
如果代码运行落入到default
分支,说明当前channel中没有数据可读。那么它会检查当前的batch
中是否有数据,如果有,就把这个batch
传递给处理函数,然后清空batch
,继续读取数据。这样已读取的数据能够及时得到处理。
如果当前的batch
中没有数据,那么它会再次进入select
语句,等待channel中有数据,或者channel被关闭,或者ctx
被取消。如果channel中没有数据,那么它会被阻塞,直到channel中有数据,或者channel被关闭,或者ctx
被取消。这样就能够及时的读取channel中的数据,而不会导致CPU空转。
通过在default
分支中的特殊处理,我们就可以低时延高效的从channel中批量读取数据了。
举一个例子哈,比如足球迷特别喜欢的欧洲冠军联赛,它的赛制就分为多个阶段:
欧洲冠军联赛由欧冠资格赛、欧冠附加赛和欧冠正赛三部分组成。
欧冠资格赛,分为预赛轮(preliminary round)、第一轮资格赛(first qualifying round)、第二轮资格赛(second qualifying round)和第三轮资格赛(third qualifying round)。第三轮资格赛的优胜的10支球队进入欧冠附加赛,附加赛优胜的6支球队(冠军之路4队、联赛之路2队)将和26支自动晋级的队伍一起,参加欧冠小组赛。
欧冠正赛分为小组赛、1/8决赛、1/4决赛、半决赛和决赛。
所以欧冠联赛分成了多个阶段,每一个阶段,会有一些球队参加,等到下一阶段,淘汰了一部分球队,又会有新的球队加入,每个阶段的球队都会有变化。这种情况非常适合使用Phaser来模拟。
Phaser
和 CyclicBarrier
的功能非常的相似,都是应用于多个参与者多阶段处理问题的场景,每个阶段都有障碍点,在障碍点需要等待所有的参与者到齐后才能进入下一个阶段。但是Phaser
比CyclicBarrier
更加灵活,CyclicBarrier
的参与者数量是固定的,所以初始化CyclicBarrier
的时候就需要设定参与者的数量,而Phaser
的参与者数量是可以动态变化的,每个阶段完成后参与者可以选择离开,新的参与者也可以加入进来,所以上面欧冠的例子非常使用Phaser
来模拟。
一旦一个同步原语的功能不是那么通用,而是面向非常细分的场景,那么它的使用范围非常有限,因为大部分场景我们都会使用WaitGroup
、channel
甚至CyclicBarrier
去解决,但是针对参与者需要动态变化的场景,我们使用Phaser
如鱼得水,比自己再封装和实现类似Phaser
的功能更方便。正所谓“技多不压身”,我们多了解一些同步原语,在解决问题的时候就会更加得心应手。
Go标准库和扩展库中都没有实现,第三方库也鲜有实现,但是Java中有,我们可以参考Java中的实现,自己实现了一个,比如 github.com/smallnest/exp/sync/Phaser,当然针对Java复杂的实现做了精简,不再支持Phaser的父子关系,函数名也做了简化,将Register/Deregister
改为Join/Leave
等,如果你之前不了解Java的Phaser,可以看看Java Phaser。
我们看看它的方法:
|
|
我们分成几个部分来看。
初始化
NewPhaser
:初始化一个Phaser,指定参与者的数量。NewPhaserWithAction
:初始化一个Phaser,指定参与者的数量,以及每个阶段的障碍点到达后的回调函数。动作
Arrive
:参与者到达障碍点,但是不等待其他参与者,直接返回当前阶段。阶段的编号从0开始,每进入一个新的阶段,阶段编号会自增1。ArriveAndLeave
:参与者到达障碍点,但是不等待其他参与者,直接返回当前阶段,并且离开Phaser。Wait
:等待指定的阶段,如果指定的阶段已经完成,直接返回,否则等待指定的阶段完成后返回。ArriveAndWait
:参与者到达障碍点,等待其他参与者到达障碍点,然后返回当前阶段。加入和离开
Join
:参与者加入Phaser,参与者的数量会自增1。Leave
:参与者离开Phaser,参与者的数量会自减1。BulkJoin
:批量加入参与者,参与者的数量会自增指定的数量。终止
ForceTermination
:强制终止Phaser,所有的参与者都会离开Phaser。查询
Arrived
:返回当前阶段已经到达障碍点的参与者数量。Parties
:返回当前Phaser中参与者的数量。Phase
:返回当前阶段的编号。IsTerminated
:返回Phaser是否已经终止。举一个例子,我在代码中加上注释,来解释代码的逻辑:
|
|