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

最近在公司的日志处理程序上做性能优化,用到了绑核的情况。背景是这样的,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个核上了。