fix: 消除配置快照并发读写导致的 fatal 崩溃
GetLiveRoomByUrl 在索引缓存未命中时会调用 RefreshLiveRoomIndexCache 写入共享的不可变配置快照的 map。该函数被列表接口、录制、监听等大量 goroutine 高频并发调用,一个 goroutine 写 map 而另一个读同一 map 时, 触发 Go 运行时的 fatal error(concurrent map read and map write), 整个进程直接退出——表现为「开始录制后一段时间报错崩溃」。 配置采用不可变快照架构(Update 复制-修改-原子替换,替换前统一 RefreshLiveRoomIndexCache),已存入的快照不应再被就地修改。本次修复: - GetLiveRoomByUrl 改为纯只读:命中缓存直接返回,未命中回退到只读线性 扫描,不再写共享快照的 map。 - 移除 putConfig / putRawConfig 中对共享快照的 RefreshLiveRoomIndexCache 调用(Marshal 不需要该缓存,后续查找也已是只读)。 - 删除无调用者且同样就地修改快照的死方法 (*Config).RemoveLiveRoomByUrl。 - 新增并发回归测试 TestGetLiveRoomByUrlConcurrent。 via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
37d1d0e7d7
commit
1a629df637
3 files changed
+51
-20
No files matched your search
+12
-18
@@ -1170,27 +1170,21 @@ func (c *Config) RefreshLiveRoomIndexCache() {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) RemoveLiveRoomByUrl(url string) error {
|
||||
c.RefreshLiveRoomIndexCache()
|
||||
if index, ok := c.liveRoomIndexCache[url]; ok {
|
||||
if index >= 0 && index < len(c.LiveRooms) && c.LiveRooms[index].Url == url {
|
||||
c.LiveRooms = append(c.LiveRooms[:index], c.LiveRooms[index+1:]...)
|
||||
delete(c.liveRoomIndexCache, url)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errors.New("failed removing room: " + url)
|
||||
}
|
||||
|
||||
func (c *Config) GetLiveRoomByUrl(url string) (*LiveRoom, error) {
|
||||
room, err := c.getLiveRoomByUrlImpl(url)
|
||||
if err != nil {
|
||||
c.RefreshLiveRoomIndexCache()
|
||||
if room, err = c.getLiveRoomByUrlImpl(url); err != nil {
|
||||
return nil, err
|
||||
// 配置快照是不可变的:所有写操作都走 Update 的“复制-修改-原子替换”路径,
|
||||
// 并在替换前调用 RefreshLiveRoomIndexCache 维护索引缓存。因此这里只做只读查找,
|
||||
// 绝不在共享快照上写 map/切片,否则会与其它 goroutine 的读发生并发读写,
|
||||
// 触发 Go 运行时的 fatal error(concurrent map read and map write)。
|
||||
if room, err := c.getLiveRoomByUrlImpl(url); err == nil {
|
||||
return room, nil
|
||||
}
|
||||
// 索引缓存未命中时回退到只读线性扫描,作为兜底(例如未经 Refresh 的手工构造配置)。
|
||||
for i := range c.LiveRooms {
|
||||
if c.LiveRooms[i].Url == url {
|
||||
return &c.LiveRooms[i], nil
|
||||
}
|
||||
}
|
||||
return room, nil
|
||||
return nil, errors.New("room " + url + " doesn't exist.")
|
||||
}
|
||||
|
||||
func (c Config) getLiveRoomByUrlImpl(url string) (*LiveRoom, error) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package configs
|
||||
|
||||
import (
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/bililive-go/bililive-go/src/pkg/ratelimit"
|
||||
@@ -121,6 +122,40 @@ func TestPlatformRateLimitRemainsDisabledAfterConfigUpdate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetLiveRoomByUrlConcurrent 保证在共享的不可变配置快照上并发查询房间时不会
|
||||
// 触发 Go 运行时的 "concurrent map read and map write" fatal error。
|
||||
// 回归此前的 bug:GetLiveRoomByUrl 在缓存未命中时会调用 RefreshLiveRoomIndexCache
|
||||
// 写入共享快照的 map,与其它 goroutine 的读并发时导致崩溃。
|
||||
// 使用 -race 运行可进一步检测数据竞争。
|
||||
func TestGetLiveRoomByUrlConcurrent(t *testing.T) {
|
||||
cfg := NewConfig()
|
||||
cfg.LiveRooms = []LiveRoom{
|
||||
{Url: "https://live.bilibili.com/1", IsListening: true},
|
||||
{Url: "https://live.bilibili.com/2", IsListening: true},
|
||||
}
|
||||
cfg.RefreshLiveRoomIndexCache()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 64; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < 500; j++ {
|
||||
// 交替命中缓存与未命中(触发原先的写-回退路径)。
|
||||
if (i+j)%2 == 0 {
|
||||
room, err := cfg.GetLiveRoomByUrl("https://live.bilibili.com/1")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "https://live.bilibili.com/1", room.Url)
|
||||
} else {
|
||||
_, err := cfg.GetLiveRoomByUrl("https://live.bilibili.com/does-not-exist")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestBackwardsCompatibility(t *testing.T) {
|
||||
// Test that old config files still work
|
||||
oldConfigYaml := `
|
||||
|
||||
@@ -952,8 +952,9 @@ func getConfig(writer http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func putConfig(writer http.ResponseWriter, r *http.Request) {
|
||||
// 直接序列化当前快照即可;索引缓存不参与序列化,且共享快照不可变,
|
||||
// 不能在此调用 RefreshLiveRoomIndexCache 写它的 map。
|
||||
config := configs.GetCurrentConfig()
|
||||
config.RefreshLiveRoomIndexCache()
|
||||
if err := config.Marshal(); err != nil {
|
||||
writeJsonWithStatusCode(writer, http.StatusBadRequest, commonResp{
|
||||
ErrNo: http.StatusBadRequest,
|
||||
@@ -1002,7 +1003,8 @@ func putRawConfig(writer http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
oldConfig := configs.GetCurrentConfig()
|
||||
oldConfig.RefreshLiveRoomIndexCache()
|
||||
// 共享快照不可变:其索引缓存在生成时已由 Update 维护好,
|
||||
// 这里只做只读查找,不能再调用 RefreshLiveRoomIndexCache 写它的 map。
|
||||
// 继承原配置的文件路径
|
||||
newConfig.File = oldConfig.File
|
||||
// 预先将旧配置中的 LiveId 迁移到新配置(相同 URL)
|
||||
|
||||
Reference in new issue
Block a user