V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
The Go Programming Language
http://golang.org/
Go Playground
Go Projects
Revel Web Framework
zkdfbb
V2EX  ›  Go 编程语言

map 的一个神奇的问题

  •  
  •   zkdfbb · 2021-04-06 11:54:56 +08:00 · 6551 次点击
    这是一个创建于 1087 天前的主题,其中的信息可能已经有所发展或是发生改变。

    使用下面的一段代码来统计链接的访问情况,使用方法就是用 Incr 每次访问加 1 单独测试的时候,比如用 wrk 来压测都挺正常的 神奇的是,一换成 nginx,c.data 里面的 key 就变得不正常,把他打印出来,发现有很多相同的 key 比如本来就只有一个 key 的,他会出现 key: 1, key: 2, key: 1, key: 1 这种,搞不懂。。。

    type Counter struct {
    	sync.RWMutex
    	data map[string]int
    }
    
    func (c *Counter) Get(key string) int {
    	c.RLock()
    	count := c.data[key]
    	c.RUnlock()
    	return count
    }
    
    func (c *Counter) Incr(key string) int {
    	c.Lock()
    	c.data[key]++
    	count := c.data[key]
    	c.Unlock()
    	return count
    }
    
    func (c *Counter) Delete(key string) {
    	c.Lock()
    	delete(c.data, key)
    	c.Unlock()
    }
    
    第 1 条附言  ·  2021-04-06 14:42:43 +08:00

    问题就是现在好像也很难复现,只能说个大概 简化的的代码大致如下所示,想要实现的就是统计那个id,然后定时同步到另外一个地方去统计 现象就是,用wrk之类的测试的时候,因为只有一个id,所以数字什么的基本都能对的上 但是实际线上用nginx的话有很多id,统计的就少很多个量级,比如几万的访问只统计出来几十个 然后打印的那个fmt.Println("access log: ", len(tmp)) 这行,len(tmp)就一直涨,涨到几千上万, 实际不重复的id只有几百个,然后输出tmp的话,就变成 map[abcde:1, abcde:1, abcde: 2...] 这样,这里abcde就是相同的,所以很奇怪

    nginx的配置也很简单,没什么特殊的

    
    upstream test_backend {
        server localhost:8098;
        keepalive 16;
    }
    
    server {
        listen 80;
        listen 443 ssl http2;
        server_name test.localhost;
    
        location / {
            proxy_pass http://test_backend;
        }
    }
    
    package main
    
    import (
    	"encoding/json"
    	"fmt"
    	"sync"
    	"time"
    
    	"github.com/gofiber/fiber/v2"
    )
    
    type Counter struct {
    	sync.RWMutex
    	data map[string]int
    }
    
    // Get exported
    func (c *Counter) Get(key string) int {
    	c.RLock()
    	count := c.data[key]
    	c.RUnlock()
    	return count
    }
    
    // Incr exported
    func (c *Counter) Incr(key string) int {
    	c.Lock()
    	c.data[key]++
    	count := c.data[key]
    	c.Unlock()
    	return count
    }
    
    // Delete exported
    func (c *Counter) Delete(key string) {
    	c.Lock()
    	delete(c.data, key)
    	c.Unlock()
    }
    
    var (
    	accessLog = Counter{data: make(map[string]int)}
    )
    
    func init() {
    	go func() {
    		ticker := time.NewTicker(time.Second * 10)
    		for range ticker.C {
    			func() {
    				accessLog.RLock()
    				data, err := json.Marshal(accessLog.data)
    				accessLog.RUnlock()
    				if err == nil {
                                            accessLog = Counter{data: make(map[string]int)}
    					tmp := make(map[string]int)
    					if err := json.Unmarshal(data, &tmp); err == nil {
    						fmt.Println("access log: ", len(tmp))
    					}
    				}
    			}()
    
    		}
    	}()
    
    }
    func handler(c *fiber.Ctx) error {
    	id := c.Params("id")
    	accessLog.Incr(id)
    	return c.Status(200).SendString("")
    }
    
    func main() {
    	app := fiber.New(fiber.Config{Prefork: false})
    	app.Get("/item/:id", handler)
    	app.Listen(fmt.Sprintf(":%d", 8098))
    }
    
    第 2 条附言  ·  2021-04-06 23:02:39 +08:00

    最小测试用例,跟nginx无关,感觉是并发访问的问题 test.go

    package main
    
    import (
    	"fmt"
    	"sync"
    	"time"
    
    	"github.com/gofiber/fiber/v2"
    )
    
    type Counter struct {
    	sync.RWMutex
    	data map[string]int
    }
    
    func (c *Counter) Incr(key string) int {
    	c.Lock()
    	c.data[key]++
    	count := c.data[key]
    	c.Unlock()
    	return count
    }
    
    var (
    	accessLog = &Counter{data: make(map[string]int)}
    )
    
    func init() {
    	go func() {
    		ticker := time.NewTicker(time.Second * 10)
    		for range ticker.C {
    			func() {
    				accessLog.Lock()
    				fmt.Println(accessLog.data)
    				accessLog.data = make(map[string]int)
    				accessLog.Unlock()
    			}()
    
    		}
    	}()
    
    }
    func handler(c *fiber.Ctx) error {
    	id := c.Params("id")
    	accessLog.Incr(id)
    	return c.Status(200).SendString("")
    }
    
    func main() {
    	app := fiber.New(fiber.Config{Prefork: false})
    	app.Get("/item/:id", handler)
    	app.Listen(fmt.Sprintf(":%d", 8099))
    }
    

    直接下面这样测试是正常的

    wrk -t10 -c500 -d10s http://localhost:8099/item/id1
    

    打印的是

    map[id1:124276]
    

    但是用下面的测试就不正常了 test.lua

    request = function()
    num = math.random(1, 2)
        path = "/item/id" .. num
        return wrk.format("GET", path)
    end
    

    然后下面这样测试

    wrk -t10 -c500 -d10s -s test.lua http://localhost:8099
    

    打印的是

    map[id1:82306 id1:15915 id1:1644 id1:24775 id1:3551 id2:28357 id2:82884 id2:2022 id2:157 id2:219908 id2:15928 id2:853 id2:27]
    

    没有看出来问题在哪里

    第 3 条附言  ·  2021-04-07 09:07:58 +08:00
    使用 Immutable: true 就可以了,看大佬们的分析学到了好多
    76 条回复    2021-04-07 19:06:23 +08:00
    skiy
        1
    skiy  
       2021-04-06 12:05:50 +08:00
    ```defer c.RUnlock()```
    yuguorui96
        2
    yuguorui96  
       2021-04-06 12:08:33 +08:00 via iPhone
    你的 nginx 不会是 fork 吧…
    zkdfbb
        3
    zkdfbb  
    OP
       2021-04-06 12:55:39 +08:00
    @yuguorui96 nginx 是多进程,但是这个 go 的后端只有一个,应该没影响啊
    zkdfbb
        4
    zkdfbb  
    OP
       2021-04-06 12:56:19 +08:00
    @skiy 这个代码简洁了一点~性能下降了一点~
    no1xsyzy
        5
    no1xsyzy  
       2021-04-06 14:04:55 +08:00
    至少这里看不出什么问题
    能提供最小可复现样本吗?
    sujin190
        6
    sujin190  
       2021-04-06 14:08:53 +08:00
    map 不可能多个 key 的,反常规的话也许是不可见字符?要么就是你输出代码有问题
    GTim
        7
    GTim  
       2021-04-06 14:10:08 +08:00
    show 你的完整测试代码
    AngryPanda
        8
    AngryPanda  
       2021-04-06 14:24:29 +08:00
    盲猜这些 key 在不同协程中
    Lpl
        9
    Lpl  
       2021-04-06 14:40:11 +08:00
    你这段代码槽点比较多,性能会很差:
    1. 可以考虑用 atomic 而不是用锁
    2. 可以考虑用管道来做 Producer-Consumer,然后多个协程消费,而不是考虑用锁
    3. 实在想加锁,就给某个 key 加锁,而不是给 Counter 对象加锁。锁的粒度太粗了

    给个比较完整的代码看看
    zkdfbb
        10
    zkdfbb  
    OP
       2021-04-06 14:48:48 +08:00
    @Lpl 批评的对🤣
    atomic 的话,由于 id 不是唯一的,好像不太方便
    没试过用管道,我直观以为管道性能会更差一点,看来我理解错了,可以试一下
    有看到过用分段锁的例子,concurrent-map 这个,不过我测了一下就用一把锁好像也足够用了就先这么用了,结果就出现了这个神奇的问题,完整的代码补上了,你看看能不能看出来哪里的问题🤣
    zkdfbb
        11
    zkdfbb  
    OP
       2021-04-06 14:49:14 +08:00
    @AngryPanda 按理加了锁应该不同的协程也是 OK 的
    zkdfbb
        12
    zkdfbb  
    OP
       2021-04-06 14:50:25 +08:00
    @no1xsyzy
    @GTim

    补在后面了,看看能不能发现什么问题
    Lpl
        13
    Lpl  
       2021-04-06 15:02:29 +08:00
    脑袋疼..你这个问题出在 init 里边的那个协程了:
    accessLog = Counter{data: make(map[string]int)}

    里边多了这句话,我理解你这个协程就是想打印下当前已存在的数据?
    makdon
        14
    makdon  
       2021-04-06 15:38:49 +08:00
    @Lpl 我理解他这里的意思是,先保存下来当前的状态,然后重置 counter
    这里的问题我理解只是重置全局 counter 的时候没有加锁,所以会丢失部分统计数据,但是不应该会如楼主所说“这里 abcde 就是相同的”

    我 review 楼主的代码没有 review 出啥问题, 复现的成本对我来说有点高。
    如果按照“这里 abcde 就是相同的” 这里来推断,因为 map 里面的 key 值都是唯一的,所以猜想可能楼主实际上使用的并不是 map[string]int, 而是自定义的结构体 /指针 /浮点数
    然后多个不同的 id,虽然内容是一致的,但是 hashkey 不一致
    zkdfbb
        15
    zkdfbb  
    OP
       2021-04-06 15:58:09 +08:00
    @makdon 不是的,我用的就是 map[string]int,统计的就是一个字符串 id 的访问次数,hashkey 是一样的
    要是正常我就不问了,问就是因为不正常😂
    我也感觉光看这段代码应该看不出什么问题,但是事实就是这么猝不及防的发生了,脑阔疼
    zkdfbb
        16
    zkdfbb  
    OP
       2021-04-06 15:58:44 +08:00
    @Lpl 这个只是个简化的例子,实际上还需要处理一下,不过意思是一样的
    joesonw
        17
    joesonw  
       2021-04-06 16:05:11 +08:00
    accessLog = Counter{data: make(map[string]int)}

    没加锁
    zkdfbb
        18
    zkdfbb  
    OP
       2021-04-06 16:17:29 +08:00
    @joesonw 是要加个锁,不过不是因为这个,我试过把这句注释掉,也是一样的
    joesonw
        19
    joesonw  
       2021-04-06 16:45:20 +08:00
    是打印那的问题吗? 没换行? 现在的打印没有打印 map, 只打了 map 的长度.
    zkdfbb
        20
    zkdfbb  
    OP
       2021-04-06 16:55:30 +08:00
    @joesonw 嗯,就是本来只要几百个 key 的,结果 map 长度可以上万,也打印过 map 本身,看到的就是有重复的 key
    ZSeptember
        21
    ZSeptember  
       2021-04-06 16:57:42 +08:00
    可以把实际重复的 key 发出来看看?
    问题应该还是 看似相同的 key 实际不同,但是具体为什么不同得具体分析了。
    zkdfbb
        22
    zkdfbb  
    OP
       2021-04-06 17:25:37 +08:00
    @ZSeptember
    @makdon
    @Lpl

    就这样,直接打印 accessLog.data 的话,有重复的 id
    ![]( https://p3-tt-ipv6.byteimg.com/origin/pgc-image/29af76c21c3f4e148252613ab6754a7c)
    zkdfbb
        23
    zkdfbb  
    OP
       2021-04-06 17:28:30 +08:00
    然后如果打印 tmp 的话,因为 json 格式化之后,重复的 id 都去掉了,就会变的特别少
    这个图,上面是打印 accessLog.data, 下面是打印 tmp

    https://p26-tt.byteimg.com/origin/pgc-image/55a1b5f43bce43058915b2a74561f1d6
    joesonw
        24
    joesonw  
       2021-04-06 17:33:40 +08:00
    枷锁了 print accessLog.data?
    lesismal
        25
    lesismal  
       2021-04-06 17:39:33 +08:00
    accessLog = Counter{data: make(map[string]int)}
    先改成
    accessLog = &Counter{data: make(map[string]int)}
    否则可能 panic

    key 的问题,Counter 的 func 都把 key 先 trim 一下

    @Lpl
    另外,9 楼 3 条没一个是对的
    lesismal
        26
    lesismal  
       2021-04-06 17:41:04 +08:00
    Lpl
        27
    Lpl  
       2021-04-06 17:46:11 +08:00
    @zkdfbb 没遇见过,看了 StackOverflow 上有个问题跟你这个有点像: https://stackoverflow.com/questions/41064208/go-map-has-duplicate-keys/41102560

    看能否在 Incr 的时候,打印一下 key 的 string 串,再用 %x 打印一下 hex 做一下对比。
    然后在输出 map 的时候,for-range 遍历下,也罢 key 的 string 和 hex 都打印下看看
    Lpl
        28
    Lpl  
       2021-04-06 17:51:37 +08:00
    @lesismal 能不能详细展开说说
    1. atomic 做的原子操作是比加锁快吧
    2. 用管道通过求摸建立多个协程来消费
    3. 目的是为了每一个 key 都能并发安全,加细粒度的锁不用去加对象锁,concurrentmap 不就是这样做的吗
    lesismal
        29
    lesismal  
       2021-04-06 18:23:12 +08:00
    @Lpl

    1. atomic 做的原子操作是比加锁快吧

    先看 3 的回答吧,因为锁不可避免,所以在这里根本不能使用 atomic

    并且因为是按 map 的 key 进行统计,首先你得取这个 map key 对应的 value,而直接取 map[key] 的地址是不行的,你试试编译下:
    m := map[string]int32{"a": 0}
    atomic.AddInt32(&m["a"], 1)
    会报错: "cannot take the address of m["a"]"


    2. 用管道通过求摸建立多个协程来消费

    相对比较复杂的并且需要保证临界区一致性的并发逻辑可以考虑用 chan 替代锁来避免锁的复杂度尤其死锁等情况,但是简单功能,就比如这种计数器,使用 chan 实现起来会比锁更麻烦并且性能稍微损失一点点,完全没必要,要从实际出发、不能照本宣科

    3. 目的是为了每一个 key 都能并发安全,加细粒度的锁不用去加对象锁,concurrentmap 不就是这样做的吗

    首先要解决获得这个 key 的 value,获得这个 key 本身就需要对 map 的锁
    标准库的 sync.Map 一样内置锁来实现、只是应用层不需要自己使用锁罢了
    其他的三方 concurrentmap 实现也并不能实现每个 key 粒度,而是为了减少 key 数量巨大时并发流的竞争,所以在标准库 map 之上再加一层 hash buckets,再每个 buckets 对应的结构上用一个锁,go 标准库自己的 map 本身就是个 hash 下面多个 buckets,三方 concurrentmap 相当于再加一层 hash 分开成多个锁来降低粒度为 bucket 级别减少竞争
    makdon
        30
    makdon  
       2021-04-06 18:23:40 +08:00
    @Lpl
    感觉你的 3 条怪怪的,但是说不出很具体很有说服力的理由。
    尝试列举一下原因:
    1. map 是用不到 atomic 的吧,没有 Google 到 atomic 跟 map 一起用的方法,如果 atomic.AddInt32(&m["a"],1) 会报错 can not take the address of m["id"],。
    就算假设我们可以拿到 addr,那我们还需要额外处理 key 不存在的时候,map 桶分配等逻辑,这时候还需要两次 hash (查询是否存在时计算一次 hashkey,插入 /递增的时候再计算一次)。hash 应该比锁重多了吧。

    2. 管道里面还是包含了一个锁,只是是拥有锁的时长变了,从“hash 需要的时间”变成了“写 channel 的时间”,但是 map 还是需要排队写入,引入个队列相当于脱裤子放屁,还引入了协程调度的开销。在这个 case 里面,map 应该是瓶颈,引入队列并没有解决这个问题。

    3. 这种锁设计,根据官方文档“The Map type is optimized for two common use cases: (1) when the entry for a given key is only ever written once but read many times, as in caches that only grow, or (2) when multiple goroutines read, write, and overwrite entries for disjoint sets of keys. In these two cases, use of a Map may significantly reduce lock contention compared to a Go map paired with a separate Mutex or RWMutex.” 很明显不属于前者,然后需要 ++;后者也不属于,因为 id 在生产上更可能是均匀的,不同协程写的值域是有交集的,仍然会产生锁竞争,性能不一定比 map with RWMutex 好
    lesismal
        31
    lesismal  
       2021-04-06 18:26:00 +08:00
    @Lpl
    我上一楼的回答的 1 中,即使 map 的 value 使用对象,然后 atomic 操作对象的 value 也不适合,因为最早 map 为空,拿不到对应的 value,如果判断空先存入,那还是要加锁,除非你的 key 数量和 key 值都是固定的、创建 map 时就初始化了 value
    makdon
        32
    makdon  
       2021-04-06 18:26:36 +08:00
    @lesismal
    几乎同时回复了同一楼,不过大佬你讲得好多了😂
    GTim
        33
    GTim  
       2021-04-06 18:32:00 +08:00
    不是锁出了问题,而是 `key` 出了问题,可能存在空格或者不可见字符等
    Orlion
        34
    Orlion  
       2021-04-06 18:34:07 +08:00
    init 函数中 accessLog = Counter{data: make(map[string]int)}这句代码不是原子的吧,换成
    accessLog.Lock()
    accessLog.data = make(map[string]int)
    accessLog.RUnlock()

    试试呢?
    love2020
        35
    love2020  
       2021-04-06 18:53:13 +08:00
    一个 map 我 瞬间进入知识盲区了
    lesismal
        36
    lesismal  
       2021-04-06 19:03:45 +08:00
    @makdon 过奖了,3q
    lesismal
        37
    lesismal  
       2021-04-06 19:07:14 +08:00
    @Orlion 对,这份代码里问题最大的就这句,accessLog 原来的内存被赋为新值的过程不原子,所以可能 panic 、还可能 Incr 的 Lock 是原来的 Mux 、Unlock 是新的 Mux 所以死锁

    我还是建议我 25 楼说的那样都改成 accessLog = &Counter{data: make(map[string]int)}
    sxfscool
        38
    sxfscool  
       2021-04-06 19:19:55 +08:00
    ```
    func main() {
    m := map[string]int{
    "abcd": 0,
    "abcd\x00": 1,
    }
    fmt.Println(m)
    }
    ```
    会打印 map[abcd:0 abcd:1]
    试下 trim 掉空白字符试试
    sxfscool
        39
    sxfscool  
       2021-04-06 19:28:30 +08:00
    newString := strings.Map(func(r rune) rune {
    if unicode.IsGraphic(r) {
    return r
    }
    return -1
    }, yourString)
    用这个处理你的输入字符串后再放到 map 里
    kcojkcnw
        40
    kcojkcnw  
       2021-04-06 19:35:24 +08:00
    @lesismal 取地址这里可以理解为一个 64 位机子的 machine word 为 8byte (即 CPU 能保证的原子操作大小),刚好是一个 pointer 的大小,所以这句是原子的,是嘛?
    ClarkAbe
        41
    ClarkAbe  
       2021-04-06 19:37:04 +08:00 via Android
    建议直接 synv.Map
    Lpl
        42
    Lpl  
       2021-04-06 20:00:56 +08:00
    @lesismal
    @makdon
    你俩是在互相对着夸夸吗?我只是给题主举几个其它方面的思路

    1. atomic 这个问题,我只是看到 Incr 自然想到的,你俩也说了,要事先知道有哪些 key,把对应的对象创建出来。
    那对于 web 应用来说,http_path 都是固定的吧?这是可以事先创建出来的。如果题主这里是随机的,可以不用考虑这一项。

    2. 对于使用管道。你们先看明白要解决的问题的根因:因为多个协程去为某个 key +1 会造成多线程不安全的问题。那我只用一个管道也可以做啊,我把拿到的所有请求全部丢给管道,管道是有序的,消费端有序处理,还需要加锁吗?

    当然,假如消费端消费的慢,可以采用多个协程求模来做。这个看实际情况分析。

    你说我照本宣科这点,恕我不敢苟同

    3. 你肯定没用过 1.8 以前没有 sync.map 的场景,或者 Java 里边的 ConcurrentHashMap 不了解。以前没有原生 sync.map 是怎么做 Concurrent 的?就是建一个 32 位的桶,把锁加在桶上边,减小锁的粒度。可以简单参考下这个: https://github.com/orcaman/concurrent-map

    @makdon
    “读写 channel 的时间” 与 “每一次加锁”,你可以写个简单的 Demo 做一下 benchmark
    lesismal
        43
    lesismal  
       2021-04-06 20:01:51 +08:00
    @kcojkcnw 对。32 位也一样,pointer 是字长,并且这种非结构体成员变量是对齐的,除了老奔腾还是哪个版本年代之前的,i32 也是原子的
    lesismal
        44
    lesismal  
       2021-04-06 20:09:13 +08:00
    @Lpl 淡定点,淡定点
    1. 每个路由预先存到这个简单的统计里,代码会很漂亮?而且这点计数功能用 mutex 性能也不是瓶颈
    2. 照本宣科这个词不是为了贬低,而是想告诉你不要听别人说好就什么都用什么,要懂得从实际出发
    3. 先看懂我回复的内容
    另外,chan 和 mutex 你可以自己先 benchmark 试试,chan 的源码在 runtime/chan.go 里,本身就带有锁的逻辑,并且跨越了协程,如果你觉得会比 mutex 性能好,那可以试试看
    makdon
        45
    makdon  
       2021-04-06 20:39:05 +08:00
    @Lpl 是我单方面夸他...
    1. 从代码来看,id 是 url path 里面取的,不固定
    2. 谁主张谁举证,大佬 benchmark 来一个?我理解只是从锁排队变成了协程排队了而已
    zkdfbb
        46
    zkdfbb  
    OP
       2021-04-06 20:42:37 +08:00
    @makdon
    @lesismal
    @GTim
    @Orlion
    @sxfscool
    @Lpl
    @ClarkAbe
    @kcojkcnw

    各位大佬,我又测试了一下,用 counter = &Counter{data: make(map[string]int)} 初始化全局变量,然后后面重新赋值的时候也加了锁,但是仍然不行,打印 accessLog.data 的时候用 "%s, %x" 打印 key,结果 hash 是一样的

    https://p26-tt.byteimg.com/origin/pgc-image/4963875f190e4ccf9b8a89fcbad8590e
    https://wkphoto.cdn.bcebos.com/d1160924ab18972b923ebfb4f6cd7b899e510a43.jpg
    lesismal
        47
    lesismal  
       2021-04-06 21:04:36 +08:00
    @zkdfbb 最好可以给一份可以复现的代码+测试用例,大家可以复现下看看
    zkdfbb
        48
    zkdfbb  
    OP
       2021-04-06 21:05:00 +08:00
    我用下面的方式测试了一下,比之前的结果要好一点,但是还是不对,但是 tmp 里面的访问次数加起来只有 nginx 的 access.log 里面的一半

    accessLog.Lock()
    tmp := make(map[string]int)
    for k, v := range accessLog.data {
    tmp[k] += v
    }
    accessLog.data = make(map[string]int)
    accessLog.Unlock()
    touchwithe
        49
    touchwithe  
       2021-04-06 21:06:19 +08:00 via iPhone
    golang 萌新看楼上的大佬都好厉害。很久没有感受到这种讨论问题的氛围了。
    no1xsyzy
        50
    no1xsyzy  
       2021-04-06 21:06:42 +08:00
    不妨打印下 string(data),如果是 \x00 的问题,json 里会被显式地表达为 '\\' 'x' '0' '0' 四个字符
    no1xsyzy
        51
    no1xsyzy  
       2021-04-06 21:26:23 +08:00
    @Lpl 1. 一来移植性有问题,二来其实你并不能确定 http_path 固定,可能只是题主的测试中固定了。

    2. chan 底层是锁实现(真锁,不是 CAS ),有时 chan 比 Mutex 效率低一个数量级,甚至不如你自己实现一个 chan
    据说标准库的 benchmark 你都找不到几个 chan
    多消费者来处理确实比单消费者快得多(因为是流水线,一个去读 chan 了另一个在写 map ),但总体而言,最后仍然不如 Mutex,何况瓶颈在 map,而且还增加 gc 开销
    这篇文章里有一个老的 benchmark: <Mhttps://bravenewgeek.com/go-is-unapologetically-flawed-heres-why-we-use-it/>

    3. 减小锁粒度至少不会使性能更糟,但这里显然是过早优化了。
    Lpl
        52
    Lpl  
       2021-04-06 21:56:42 +08:00
    @makdon
    @lesismal
    写了一个简单的测了下,性能确实差 Mutex 挺多。是我滥用了
    package counter

    type ChanCounter struct {
    data map[string]int
    resultChan chan *string

    stopChan <-chan struct{}
    }

    func NewChanCounter(stopChan <-chan struct{}) *ChanCounter {
    c := &ChanCounter{
    stopChan: stopChan,
    resultChan: make(chan *string, 10000),
    data: make(map[string]int),
    }
    go c.run()
    return c
    }

    func (c *ChanCounter) Incr(key *string) {
    c.resultChan <- key
    }

    func (c *ChanCounter) run() {
    for {
    select {
    case r := <-c.resultChan: {
    c.data[*r]++
    }
    case <-c.stopChan:
    break
    }
    }
    }
    zkdfbb
        53
    zkdfbb  
    OP
       2021-04-06 23:03:34 +08:00   ❤️ 1
    @makdon
    @lesismal
    @GTim
    @Orlion
    @sxfscool
    @Lpl
    @ClarkAbe
    @kcojkcnw

    我补了一份最小测试用例,你们看看能不能复现,我仍然是一头雾水
    zkdfbb
        54
    zkdfbb  
    OP
       2021-04-06 23:08:20 +08:00   ❤️ 1
    如果是 test.lua 是

    request = function()
    num = math.random(1, 1)
    path = "/item/id" .. num
    return wrk.format("GET", path)
    end


    就是链接唯一的话,又正常
    dallaslu
        55
    dallaslu  
       2021-04-06 23:38:23 +08:00
    很有意思的现象。我跑了一下,打印了 Incr 的返回值和 accessLog.data:

    ```
    access log: 34reafas0fasdfre7 = 1
    access log: 34reafas0fasdfre8 = 1
    access log: 34reafas0fasdfre8 = 2
    access log: 34reafas0fasdfre8 = 1
    access log: 34reafas0fasdfre8 = 1
    access log: 34reafas0fasdfre8 = 1
    access log: 34reafas0fasdfre8 = 1
    map[34reafas0fasdfre8:1 34reafas0fasdfre8:6]
    ```
    dallaslu
        56
    dallaslu  
       2021-04-06 23:45:44 +08:00
    @dallaslu

    1. 只有一个 id 的话没有问题
    2. 与 Nginx 无关
    3. 经过多次测试,总计数是正确的,只是个别 key 打印时显示为其他 key
    3. Incr 返回值看起来遇到了并发问题的 key,一般也覆盖了其他 key
    zkdfbb
        57
    zkdfbb  
    OP
       2021-04-07 00:03:01 +08:00
    @dallaslu 确实是这样
    makdon
        58
    makdon  
       2021-04-07 00:58:29 +08:00
    app := fiber.New(fiber.Config{Prefork: false, Immutable: true})
    这样写就正常了
    初略看了下代码,不使用 immutable 时,框架是直接用 unsafe 把 []byte 转成 string 然后抛出
    感觉跟内存块复用 /golang memory model 有关
    具体还得仔细看看源码
    dallaslu
        59
    dallaslu  
       2021-04-07 01:06:20 +08:00
    刚又忍不住改代码实验了一下,估计可以结帖了。待楼主验证。

    楼主的问题和并发呀、锁呀之类的可能没有关系,和 fiber 倒是有关系。

    map 肯定不会出现重复 key 的。所以当打印 map 时,如果显示有相同的 key,那么这个 key 一定是被鬼附了身。

    正如 @makdon #45 所说,「 id 是 url path 里面取的」,那么鬼就是从 fiber 来的,可能是 fiber 重复使用了内存空间。所以我改了一下:

    ```
    accessLog.Incr(id + "")
    ```

    然后问题就再没复现过了。
    dallaslu
        60
    dallaslu  
       2021-04-07 01:07:58 +08:00
    @makdon 听上去靠谱 :)
    dallaslu
        61
    dallaslu  
       2021-04-07 01:13:01 +08:00
    @makdon #58 正解,我这里测试通过。Immutable 的注释: …so that these values are available even if you return from handler.
    no1xsyzy
        62
    no1xsyzy  
       2021-04-07 01:26:12 +08:00
    如果把 fiber 除掉,go 2 个协程或 20 个协程持续 accessLog.Incr (分别以 id1 和 id2 ),也是完全正常的没有重复键。
    更精细地,range accessLog.data 来打印,随手写了对比相邻行 key,当显示相等时 last_key == key 得 true
    json.Marshal 的结果所有的值都是同一个值,显然相互覆盖了。

    我暂时搁置了,放一些思路:
    试下 gin ?

    这最后可能要涉及到 map 的实现问题。op 先把目前的结果放 StackOverflow 吧。
    makdon
        63
    makdon  
       2021-04-07 01:41:24 +08:00   ❤️ 3
    原因应该是这样的,没有很深入看 map 的源码(src/runtime/map.go),所以带有猜测成分
    首先我们往 map 插入一个 key 为 id1 的
    然后完成请求后,刚刚 key 所占用的 []byte 被重复利用,key 变成了 id2
    猜测 map 的实现里面没有拷贝一次 string,所以 map 里面的 key 变成了 id2,但是 hash 还是之前 id1 的 hash
    然后分两种情况:
    - 新插入 id1,!t.key.equal(key, k), 所以给它分配了一个新的桶
    - 新插入 id2,原有的 id2 跟新的 id2 hash 不相等,不会覆盖,还是给它新分配一个新的桶

    可以通过一个小 demo,复现这个 case,获得一个 200 个 key,每个 key 都是 id1 的 map
    func main(){
    m := make(map[string]int)
    for i := 0; i < 200; i++{
    b := []byte("id2")
    str := *(*string)(unsafe.Pointer(&b))
    m[str]++
    b[2] = '1'
    }
    fmt.Println(m)
    fmt.Println(len(m))
    }
    zkdfbb
        64
    zkdfbb  
    OP
       2021-04-07 09:07:10 +08:00
    @dallaslu
    @no1xsyzy
    @makdon

    感谢分析~ 用 Immutable: true 就正常了,可结贴了
    GTim
        65
    GTim  
       2021-04-07 10:32:44 +08:00
    说来你们可能不相信,这是 `fiber` 导致的问题,哎,以后有机会深究

    ```go
    package main

    import (
    "fmt"
    "github.com/gin-gonic/gin"
    "net/http"
    "sync"
    )

    type Counter struct {
    sync.RWMutex
    data map[string]int
    }

    func (c *Counter) Incr(key string) int {
    c.Lock()
    c.data[key]++
    count := c.data[key]
    fmt.Println(c.data)
    c.Unlock()
    return count
    }

    var (
    accessLog = &Counter{data: make(map[string]int)}
    )

    func handler(c *gin.Context) {
    id := c.Param("id")
    accessLog.Incr(id)
    c.String( http.StatusOK, "")
    }

    func main() {
    router := gin.Default()
    router.GET("/item/:id", handler)
    router.Run(":8099")
    }

    ```

    用原生的 `net/http` 或者 `gin` 就不会出错
    lesismal
        66
    lesismal  
       2021-04-07 11:27:08 +08:00
    @GTim 不用以后深入研究,就今天吧

    不能怪 fiber,fiber 是基于 fasthttp 的,以前看过 fasthttp 相关介绍大概意思是 fasthttp 为了性能,很多地方 pool 复用内存,虽然我没有读 fasthttp 源码,只是大概分析,但是大致原因应该是差不多的:

    应用层获取 http 各种参数时是复用的[]byte unsafe 的方式强转成 string,类似 c/c++的浅拷贝,新的 string 和原来的[]byte 类型结构体的数据指针指向同一段内存,而在本次 handler 调用结束后,这段[]byte 就被放回了 pool 并且以后有新的地方使用时又被拿出来
    比如楼主的 key 加入到 map 时字面值是 "a",按照 "a" 的 hash index 存到对应的 map 的 bucket 里,而这个 string "a" 的结构体内部指向的内存被放回 pool,其他地方再次从 pool get 到时就可能被复用的地方修改,比如刚好其他请求的这个 key 复用了 "a" 的同一段内存但是这些请求的 key 为"b","b" 加到 map 里的时候是按照 "b" 的 hash index 存到对应的 bucket 里的、不同的 hash index 则不碰撞、不会跟原来的那个 string "a"(当前字面值也是"b")比较,所以就产生了多个 key

    实在不喜欢 fasthttp
    但是 fiber 的接口 /API 设计看着比 gin 舒服,还是挺喜欢的
    但是生产项目,我还是不打算用 fasthttp 系的
    lesismal
        67
    lesismal  
       2021-04-07 11:34:51 +08:00
    @Lpl 恩恩,这种简单功能都不会性能瓶颈,主要还要考虑设计上的复杂度,chan 不是万能灵药,毕竟加了一层 chan,同步逻辑变成了异步逻辑,换成 chan 的实现也不比 mutex 来得简洁,并且也不如用 mutex 容易理解

    锁是很基础的设施,不要怕用它
    lesismal
        68
    lesismal  
       2021-04-07 11:37:02 +08:00
    @makdon
    原来 63 楼已经回复过了,66 楼多余了,缘分
    lesismal
        69
    lesismal  
       2021-04-07 11:40:20 +08:00
    @makdon 63 楼回答的好
    @Lpl 现在是互夸了

    enjoy coding, have fun ~
    zkdfbb
        70
    zkdfbb  
    OP
       2021-04-07 11:56:45 +08:00
    @lesismal 我也是看 fiber 接口设计比较舒服,基本的功能也都比较完备,然后好像还说作者全职在做这个所以用的,之前就看到过一篇文章说高并发下有问题不过也没太在意。。。生产环境下有啥好推荐的么

    https://cloud.tencent.com/developer/news/462918
    lesismal
        71
    lesismal  
       2021-04-07 12:05:20 +08:00
    @zkdfbb 基于标准库的知名框架都比较稳,功能和周边也都差不多,按 star 就 gin,按喜好就看自己了
    qieqie
        72
    qieqie  
       2021-04-07 12:08:55 +08:00
    9 楼一条没说对是真的
    另外说一个反直觉但是能提升 map[string]int ++这样操作的 trick,就是换成 map[string]*int
    参考: https://github.com/golang/go/issues/45021
    zkdfbb
        73
    zkdfbb  
    OP
       2021-04-07 12:23:49 +08:00
    @qieqie 这样确实会更快一点,不过应该基本无影响。就像很多框架的 benchmark 差很多,但是架不住加上自己的业务逻辑后都差不多了。。。
    fenglangjuxu
        74
    fenglangjuxu  
       2021-04-07 16:35:07 +08:00
    @Lpl #52 理论小白 诚心求教 无意冒犯

    ```
    case r := <-c.resultChan:
    {
    // ???这里不用加锁么???
    c.data[*r]++
    }
    ```
    makdon
        75
    makdon  
       2021-04-07 17:24:49 +08:00 via iPhone
    @fenglangjuxu 他用 chan 排队了,一个协程专门处理 data,其它协程访问不到
    fenglangjuxu
        76
    fenglangjuxu  
       2021-04-07 19:06:23 +08:00
    @makdon #75 奥 多谢解答
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   我们的愿景   ·   实用小工具   ·   1370 人在线   最高记录 6543   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 38ms · UTC 17:39 · PVG 01:39 · LAX 10:39 · JFK 13:39
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.