golang程序并发读写全局变量,导致空指针异常
Tag golang, 数据竞争, 线程安全, on by view 114

最近将一个golang程序由原本的单线程改为双线程处理日志解析,在生产环境节点上运行发现出现了一个空指针异常,异常信息如下:

[E] 2023/02/27 11:53:25 panic.go:838: parse error:runtime error: invalid memory address or nil pointer dereference,line:Feb 27 11:53:25 xxxxx.site nginx: [xxxxxxxxxxxxxxxxxxx] [27/Feb/2023:11:53:25 +0800] [https] [120.232.31.196:443] [xxxxx.map.xx.com] [39.144.41.41:37647] [200] [30.171.153.132:10000] [200] [3339597] [POST /tr?mllc HTTP/1.1] [621] [152] [xxxxxxx.map.xx.com] [Dalvik/2.1.0 (Linux; U; Android 10; HMA-AL00 Build/HUAWEIHMA-AL00)] [-] [0.008] [0.008] [0.004] [0.008] [46795] [311532943564] [1][1:46:0:0:0:0:0] [ECDHE-RSA-AES128-GCM-SHA256] [TLSv1.2] [r][-] [-] [n] [2358837] [-1] [1677470005.398|5|51|126|-1|-1|126|126|130|-1|-1|130|134|134|134|134#200|200|8|152|120.232.31.196|30.171.153.132:10000|0|0] [POST] [/tr] [mllc] [HTTP/1.1] [39.144.41.41] [-] [-] [-] [-] [-] [-] [-] [-] [-] [-] [169.254.213.29:50937] [0]

修改程序,打印出异常栈 x7s4uzea

发现异常发生在代码133行,代码如下 0zhhoxbp

很明显这里不太可能出现空指针,除非运行到这一行的时候sl对象被置为nil,但是我很确定这里不存在其他线程共享sl的情况,也就是不可能被其他线程置为nil,何况,我这里没有任何操作将sl置为nil,百思不得其解。最后发现key1key2这两个变量是全局变量,全局变量在多线程环境下会存在数据竞争问题。原本定义如下

var (
	uriSep *regexp.Regexp = nil

	key1 = ""
	key2 = ""
)

可以看到key1,key2被定义为全局变量,忘记修改了。修改之后,神奇的发现,空指针异常已经不再存在了。

我在发现这个问题之前,在本地开发服务器上尝试重现,但是一直未能重现出来,估计是我本地qps不够高,所以难以复现,生产环境qps是3w到4w左右,这个空指针异常呈现无规律的隔几秒钟出现一次。

golang中,双协程(绑定在双M和双cpu上)中同时读写一个共享变量导致空指针异常,这种情况我还是第一次遇见,以前遇到这种双协程读写一个共享变量的情况都是数据错乱,并没有空指针。据说这种线程安全、数据竞争导致的空指针异常在C++中也是常见的情况。所以,我在想,这里会不会是因为我将2个协程绑核了,所以在双线程绑双核的情况下更容易复现呢。


初学rust,如何在线程中调用成员方法
Tag rust, 线程, 方法, on by view 104

如何在线程中调用成员方法?

普通调用习惯写法

fn watch_receiver(mut self, rx: Receiver<String>) {
    thread::spawn(move || {
        for line in rx.iter() {
            self.push(line);
        }
    });
}

会报错

p.watch_receiver(rx);
    |           ------------------ `p` moved due to this method call
70  |         p.watch_poly();
    |         ^^^^^^^^^^^^^^ value borrowed here after move

这里需要把形参self改为指针&self,然后在方法体中克隆这个指针,就可以在方法中的线程里直接通过这个指针的克隆成员方法。

改为

fn watch_receiver(&self, rx: Receiver<String>) {
    let mut me = self.clone();
    thread::spawn(move || {
        for line in rx.iter() {
            me.push(line);
        }
    });
}

即可通过。

但是要注意,这里的clone,真的是克隆。所以clone前后的变量,即selfme是2个不同的变量,里面的成员也是在不同的内存空间上,修改self中的成员属性,me中对应的成员属性并不会跟着变。所以,如果里面的成员属性需要跟随变化,必须把成员属性定义为指针,这样修改指正所指的值,selfme中成员属性所指的值是相同的。


为go程序的协程绑核
Tag golang, 绑核, on by view 706

最近在公司的日志处理程序上做性能优化,用到了绑核的情况。背景是这样的,nginx进行http转发,产生日志,然后我们的程序读取日志,用lexer分词器对日志分隔字段,并且对字段进行统计聚合上报,生成监控。日志处理程序最开始是在单个goroutine里进行读取并且解析操作了,但是在核数比较多的大机器上,发现日志生成太快,解析程序处理不过来,在日志rotate的过程中会发生丢失日志的情况。于是针对这个情况进行了优化。

用pprof发现,性能消耗最大的部分是lexer,lexer其实就是个分词器,编译器中常用的技术,逐字符读取每行日志,然后基于状态机状态标记对日志的字段进行分割,中间涉及到的状态也不算太多,主要是双引号(“”)作为定界符提取字符串字段,方括号([])作为定界符提取字符串字段,空字符(空格、\t)和竖线符(|)作为分隔符分隔字段,转义符()对字符串中的字符串定界符(”[])进行转义,总体来说,状态不算复杂,其中也针对lexer优化过尽量减少变量分配和杜绝变量逃逸,lexer实在是已经无可优化了。

于是只好从其他方面下手,首先就是cpu切换的性能损耗。众所周知golang中没有线程的,golang中只有协程(goroutine),而防止cpu切换的性能损耗只有绑核这个方法,具体就是讲指定的核绑定到某个线程上,这样这个线程就会只在这个指定的核上运行,不会被系统切换到其他核上,这样也就不会产生切换的损耗了。但是golang程序中只有goroutine,不能直接操作线程。其实我们是有办法对goroutine进行绑核的。

首先,使用go里面的runtime.LockOSThread()将当前goroutine绑定到它所在的M线程,这样,这个goroutine就不会在M线程之间切换了;然后,我们可以使用cgo,调用pthread_self获取当前协程所在M线程的线程ID,并调用CPU_SET对这个线程ID设置cpuid绑定。具体如下

package affinity

/*
#define _GNU_SOURCE
#include <sched.h>
#include <pthread.h>

int lock_thread(int cpuid) {
  pthread_t tid;
  cpu_set_t cpuset;

  tid = pthread_self();
  CPU_ZERO(&cpuset);
  CPU_SET(cpuid, &cpuset);
  return  pthread_setaffinity_np(tid, sizeof(cpu_set_t), &cpuset);
}

pthread_t current_thread_id() {
  pthread_t tid;

  tid = pthread_self();

  return tid;
}
*/
import "C"

import (
	"fmt"
	"runtime"
)

// SetAffinity 设置CPU绑定
func SetAffinity(cpuID int) (uint64, error) {
	runtime.LockOSThread()
	ret := C.lock_thread(C.int(cpuID))
	tid := uint64(C.ulong(C.current_thread_id()))
	if ret > 0 {
		return 0, fmt.Errorf("set cpu core affinity failed with return code %d", ret)
	}
	return tid, nil
}

这样一来,我们只需要在goroutine中调用SetAffinity就可以将指定的cpuid和当前goroutine进行绑定。这样就实现了goroutine的绑定。

我将日志处理程序改为在主协程中读取文件并且通过channel分发日志行,然后在2个goroutine执行最占cpu的lexer及后续处理,并且在这两个goroutine中绑定cpuid为1,2。

qdx9l7z5

图中可以看到,两个处理日志的goroutine绑定了1,2两个cpu,并且不会切为其他cpu,这两个cpu都在处理日志,所以cpu占用都比较高,相当于把原来一个核处理的任务分担到2个核上了。


观澜版画村春游记
Tag 观澜, 春游, on by view 46

春天已经悄悄到来,南方大城市中很难感受到春的变化。除非出游去远离都市的郊区,本着去看油菜花的目的,上周末去逛了一下深圳边缘的观澜版画村。

勤劳的小蜜蜂🐝 betv6288

油菜花田 kcxao9mw

芦苇 x47lhgk3

这天天气很炎热,人也很多。


M1 Macbook Pro 使用初体验
Tag M1, Macbook, on by view 87

最近体验了一段时间 M1 芯片的 Macbook Pro,我不得不承认,是我当初低估了它。简单的聊一下使用体验。

首先说一下我的日常使用电脑场景,vscode 窗口若干(3到5个窗口),远程开发。chrome 3到4组,每组20-30个tab,微信,QQ,企业微信(消息量4k+),基于electron的ssh终端,Microsoft TODO,Microsoft Remote Desktop,基于Chrome插件的youtube客户端(偶尔划个水)。

我原来的 Macbook Pro 是 Intel i7 的,内存32G,用起来不卡顿,但是风扇能够听到明显的声音,键盘很热,Touchbar位置的外壳烫手。

我现在体验的 Macbook Pro 14寸是M1处理器(8核,4E4P),16G内存。虽然只有16G内存,使用起来一点也不卡顿,并且键盘几近冰凉,Touchbar位置外壳仅有一点点温度。

p9uz43hp

可以看到,温度只有43度,风扇根本没转起来,事实上这台电脑从我拿到手到现在一个多星期风扇几乎一直是没转过。当然,还有一个很明显的地方就是,不插电的情况下续航时间明显比我原来的 Macbook Pro 长多了。

对比一下我 Intel 处理器的 Macbook Pro。

jpttxbxw

注意,这是我使用 Turbo Boost Switcher 强制关闭了 CPU 的 Boost 能力之后的表现,且是开机5分钟后的温度(并没有重度使用),然而多使用一会儿之后,温度很快就到达60度以上了。

总结一下,M1是真香,流畅、凉爽、持久。