golang 비동기. tcp 서버(4). netpoller 1부. golang 내부 epoll의 생성
해당 아티클은 다음 수준의 지식을 요구합니다.
- POSIX 네트워크 syscall
- epoll
POSIX 네트워크 syscall과 epoll을 이용하여 echo 서버를 구현했습니다. 이번에는 netpoller 내부에서 이 syscall이 어떻게 활용되는지, 또 golang에서 네트워크는 어떻게 구현되어 있는지 살펴보도록 하겠습니다.
양이 너무 많아서 이번 아티클에서 listen과 관련된 부분과 epoll이 어디서 등장하는지 알아보고 accept이후 과정은 다음 아티클에서 다뤄보도록 하겠습니다.
golang net.Listen 내부 탐색
지금까지 POSIX syscall과 epoll을 이용하여 tcp echo 서버를 만들었습니다. 그럼 다시 궁금해집니다. golang의 기본 네트워크 패키지인 net에서는 어떻게 네트워크 동작을 구현하고 있을까요? 또 어떻게 golang 런타임과 연결될까요?
다음 코드 내부를 살펴보겠습니다.
func main() {
listener, err := net.Listen("tcp", "8989")
// ...
}
해당 코드는 인터페이스 형식으로 구성되어 있고 linux의 경우 다음 함수가 호출됩니다.
https://github.com/golang/go/blob/a10e42f219abb9c5b/src/net/tcpsock_posix.go#L178
func (sl *sysListener) listenTCP(ctx context.Context, laddr *TCPAddr) (*TCPListener, error) {
return sl.listenTCPProto(ctx, laddr, 0)
}
이 함수는 반환값으로 *TCPListener를 전달하고 있고 다음 필드를 가집니다.
https://github.com/golang/go/blob/a10e42f219abb9c5b/src/net/tcpsock.go#L291
// TCPListener is a TCP network listener. Clients should typically
// use variables of type [Listener] instead of assuming TCP.
type TCPListener struct {
fd *netFD
lc ListenConfig
}
이제 깊게 들어가봅시다!
net.Listen이 호출됐을 때 net.listen으로부터 가장 먼저 호출되는 system call 관련 함수는 socket입니다.
https://github.com/golang/go/blob/a10e42f219abb9c5b/src/net/sock_posix.go#L18
// socket returns a network file descriptor that is ready for
// asynchronous I/O using the network poller.
func socket(ctx context.Context, net string, family, sotype, proto int, ipv6only bool, laddr, raddr sockaddr, ctrlCtxFn func(context.Context, string, string, syscall.RawConn) error) (fd *netFD, err error) {
// socket() 호출로 논블로킹 소켓 생성
s, err := sysSocket(family, sotype, proto)
if err != nil {
return nil, err
}
// 소켓 옵션 부여
if err = setDefaultSockopts(s, family, sotype, ipv6only); err != nil {
poll.CloseFunc(s)
return nil, err
}
// 해당 소켓에 대한 *netFD 생성
if fd, err = newFD(s, family, sotype, net); err != nil {
poll.CloseFunc(s)
return nil, err
}
// tcp 서버의 경우
if // ... {
switch sotype {
case syscall.SOCK_STREAM, syscall.SOCK_SEQPACKET:
if err := fd.listenStream(ctx, laddr, listenerBacklog(), ctrlCtxFn); err != nil {
fd.Close()
return nil, err
}
return fd, nil // 이 위치에서 리턴
// ...
}
}
}
socket()함수 내부를 살펴보면 다음과 같이 로직이 진행됩니다.
- 논블로킹 소켓 생성
- 소켓 옵션 부여
- 소켓에 대한 *netFD 생성 (소켓에 대응되는 어떤 구조체를 생성한다고 이해합니다)
- tcp 서버의 경우, fd.listenStream 메서드 호출
즉, golang 내부의 socket()함수는 소켓을 생성 & 설정하고 tcp 서버로 준비시키는 과정을 진행합니다(udp면 udp 서버, 클라이언트면 클라이언트 연결을 진행합니다).
다음은 fd.listenStream 메서드입니다.
https://github.com/golang/go/blob/a10e42f219abb9c5b/src/net/sock_posix.go#L150
func (fd *netFD) listenStream(ctx context.Context, laddr sockaddr, backlog int, ctrlCtxFn func(context.Context, string, string, syscall.RawConn) error) error {
var err error
// reuseaddr 옵션 활성화
if err = setDefaultListenerSockopts(fd.pfd.Sysfd); err != nil {
return err
}
// 주소 가져오기
var lsa syscall.Sockaddr
if lsa, err = laddr.sockaddr(fd.family); err != nil {
return err
}
// ...
// 주소 바인딩
if err = syscall.Bind(fd.pfd.Sysfd, lsa); err != nil {
return os.NewSyscallError("bind", err)
}
// listen()호출
if err = listenFunc(fd.pfd.Sysfd, backlog); err != nil {
return os.NewSyscallError("listen", err)
}
// *netFD의 초기화
if err = fd.init(); err != nil {
return err
}
// ...
return nil
}
listenStream 내부에서는 다음 작업이 일어납니다.
- sockopts로 reuseaddr를 활성화
- 주소를 소켓에 바인딩
- 서버를 호스팅
- fd.Init()으로 *netFD 초기화
따라서 net.Listen()이 호출되면 다음 작업이 발생한다고 볼 수 있습니다.
- 소켓 생성 및 소켓 옵션 부여
- 소켓에 대한 *netFD 생성
- 서버 바인딩 및 호스팅
- *netFD 초기화
- *TCPListener 내부 필드 초기화 및 리턴
그림으로 표현하면 다음과 같이 만들 수 있습니다.

netFD 필드
위에서 확인한 netFD는 다음 필드를 가집니다. 네트워크 fd를 식별하기 위한 구조체입니다. 한편, netFD는 pfd라는 poll.FD 구조체를 임베딩합니다.
https://github.com/golang/go/blob/a10e42f219abb9c5b/src/net/fd_posix.go#L17
// Network file descriptor.
type netFD struct {
pfd poll.FD
// immutable until Close
family int // 주소 식별
sotype int // udp, tcp 식별
isConnected bool // handshake completed or use of association with peer
net string // 네트워크 유형: tcp, udp ...
laddr Addr // 소켓 로컬 주소
raddr Addr // 소켓 원격 주소
}
다음은 netFD의 생성 함수입니다.
https://github.com/golang/go/blob/a10e42f219abb9c5b/src/net/fd_unix.go#L26
func newFD(sysfd, family, sotype int, net string) (*netFD, error) {
ret := &netFD{
pfd: poll.FD{
Sysfd: sysfd,
IsStream: sotype == syscall.SOCK_STREAM,
ZeroReadIsEOF: sotype != syscall.SOCK_DGRAM && sotype != syscall.SOCK_RAW,
},
family: family,
sotype: sotype,
net: net,
}
return ret, nil
}
poll.FD는 fd를 실제로 나타내는 역할을 합니다. net, os 패키지에서 이 구조체를 활용하여 네트워크 연결이나 파일을 나타냅니다.
https://github.com/golang/go/blob/a10e42f219abb9c5b/src/internal/poll/fd_unix.go#L18
// FD is a file descriptor. The net and os packages use this type as a
// field of a larger type representing a network connection or OS file.
type FD struct {
// Lock sysfd and serialize access to Read and Write methods.
fdmu fdMutex // RW 동시 접근 방지용 락
// System file descriptor. Immutable until Close.
Sysfd int // 시스템 파일 디스크립터의 정수 값. 운영체제 수준의 FD. Close 전까지 불변
// Platform dependent state of the file descriptor.
SysFile // 운영체제에 따라 달라지는 파일 디스크립터의 상태를 의미
// I/O poller.
pd pollDesc // IO polling에 대한 정보를 담는 구조체
// Semaphore signaled when file is closed.
csema uint32 // 파일이 닫혔을 때에 대한 시그널 처리
// Non-zero if this file has been set to blocking mode.
isBlocking uint32 // 블로킹 모드 여부 체킹
// Whether this is a streaming descriptor, as opposed to a
// packet-based descriptor like a UDP socket. Immutable.
IsStream bool // TCP UDP 구분자
// Whether a zero byte read indicates EOF. This is false for a
// message based socket connection.
ZeroReadIsEOF bool // 읽기 작업 시 0바이트를 읽으면 EOF로 처리할 지 결정
// Whether this is a file rather than a network socket.
isFile bool // 네트워크, 파일 기반 소켓 유무 처리. nfd면 false
}
이 부분에서 중요한 필드가 하나 더 등장합니다. 바로 pd입니다. pd의 타입은 pollDesc이며, 런타임에서 아래와 같이 정의됩니다.
https://github.com/golang/go/blob/a10e42f219abb9c5b/src/runtime/netpoll.go#L75
// Network poller descriptor.
//
// No heap pointers.
type pollDesc struct {
_ sys.NotInHeap
link *pollDesc // in pollcache, protected by pollcache.lock
fd uintptr // pollDesc 수명 동안 불변인 fd
fdseq atomic.Uintptr // protects against stale pollDesc
// atomicInfo holds bits from closing, rd, and wd,
// which are only ever written while holding the lock,
// summarized for use by netpollcheckerr,
// which cannot acquire the lock.
// After writing these fields under lock in a way that
// might change the summary, code must call publishInfo
// before releasing the lock.
// Code that changes fields and then calls netpollunblock
// (while still holding the lock) must call publishInfo
// before calling netpollunblock, because publishInfo is what
// stops netpollblock from blocking anew
// (by changing the result of netpollcheckerr).
// atomicInfo also holds the eventErr bit,
// recording whether a poll event on the fd got an error;
// atomicInfo is the only source of truth for that bit.
atomicInfo atomic.Uint32 // closing, rd, wd 플래그를 요약하여 보유
// rg, wg are accessed atomically and hold g pointers.
// (Using atomic.Uintptr here is similar to using guintptr elsewhere.)
rg atomic.Uintptr // 읽기 작업 대기 goroutine 포인터
wg atomic.Uintptr // 쓰기 작업 대기 goroutine 포인터
lock mutex // protects the following fields
closing bool
user uint32 // pollDesc에 대한 메타 데이터
rseq uintptr // protects from stale read timers
rt timer // 읽기 데드라인 타이머
rd int64 // read deadline (a nanotime in the future, -1 when expired)
wseq uintptr // protects from stale write timers
wt timer // 쓰기 데드라인 타이머
wd int64 // write deadline (a nanotime in the future, -1 when expired)
self *pollDesc // storage for indirect interface. See (*pollDesc).makeArg.
}
이때 listenStream()에서 호출되는 (*netFD).init() 내부 주요 부분은 다음과 같습니다.
// https://github.com/golang/go/blob/a10e42f219abb9c5b/src/net/fd_unix.go#L40
func (fd *netFD) init() error {
return fd.pfd.Init(fd.net, true)
}
// https://github.com/golang/go/blob/a10e42f219abb9c5b/src/internal/poll/fd_unix.go#L54
func (fd *FD) Init(net string, pollable bool) error {
// pollable을 true로 전달
fd.SysFile.init()
// ...
err := fd.pd.init(fd)
if err != nil {
// ...
}
return err
}
// https://github.com/golang/go/blob/a10e42f219abb9c5b/src/internal/poll/fd_poll_runtime.go#L38
var serverInit sync.Once
func (pd *pollDesc) init(fd *FD) error {
serverInit.Do(runtime_pollServerInit) // sync.Once로 최초 한 번만 호출
ctx, errno := runtime_pollOpen(uintptr(fd.Sysfd))
if errno != 0 {
return errnoErr(syscall.Errno(errno))
}
pd.runtimeCtx = ctx
return nil
}
한 번 netFD의 초기화 과정을 요약해봅시다.
- netFD가 생성되고 init 호출
- fd의 pollDesc의 init 호출
- runtime_pollServerInit는 init이 여러 번 호출되어도 최초 한 번만 호출
- runtime_pollOpen 호출
여기서 runtime_pollXXX로 붙은 함수를 확인할 수 있습니다. 해당 함수가 linux epoll을 golang에서 사용하는 진입점과 같습니다. 이 부분에서 호출되는 부분은 runtime_pollServerInit과 runtime_pollOpen입니다. 이제부터 지금까지 공부한 epoll을 확인할 수 있는 부분이 시작됩니다. 그럼 들어가봅시다.
golang 내부의 epoll
runtime_pollServerInit는 리눅스에서 poll_runtime_pollServerInit으로 호출됩니다. ✅부분 위주로 보시면 됩니다.
// https://github.com/golang/go/blob/a10e42f219abb9c5b/src/runtime/netpoll.go#L208
//go:linkname poll_runtime_pollServerInit internal/poll.runtime_pollServerInit
func poll_runtime_pollServerInit() {
netpollGenericInit()
}
func netpollGenericInit() {
if netpollInited.Load() == 0 {
lockInit(&netpollInitLock, lockRankNetpollInit)
lock(&netpollInitLock)
if netpollInited.Load() == 0 {
netpollinit() // ✅이 부분에서 epoll 생성
netpollInited.Store(1)
}
unlock(&netpollInitLock)
}
}
// https://github.com/golang/go/blob/a10e42f219abb9c5b/src/runtime/netpoll_epoll.go#L23
func netpollinit() {
var errno uintptr
// ✅epoll 생성
epfd, errno = syscall.EpollCreate1(syscall.EPOLL_CLOEXEC)
if errno != 0 {
// ...
}
// ✅epoll 중단 시그널용 파이프 생성
r, w, errpipe := nonblockingPipe()
if errpipe != 0 {
// ...
}
// ✅파이프 읽기 fd를 epoll에 등록
ev := syscall.EpollEvent{
Events: syscall.EPOLLIN,
}
*(**uintptr)(unsafe.Pointer(&ev.Data)) = &netpollBreakRd
errno = syscall.EpollCtl(epfd, syscall.EPOLL_CTL_ADD, r, &ev)
if errno != 0 {
// ...
}
netpollBreakRd = uintptr(r)
netpollBreakWr = uintptr(w)
}
위 코드의 동작을 요약하면 다음과 같습니다.
- epoll을 생성합니다.
- 파이프를 생성합니다. 파이프의 역할은 epoll을 중지시키는 시그널을 전달할 때 사용합니다.
- 파이프의 읽기 fd를 EpollCtl로 등록합니다.
runtime_pollOpen은 리눅스에서 poll_runtime_pollOpen으로 호출됩니다.
// https://github.com/golang/go/blob/a10e42f219abb9c5b/src/runtime/netpoll.go#L237
//go:linkname poll_runtime_pollOpen internal/poll.runtime_pollOpen
func poll_runtime_pollOpen(fd uintptr) (*pollDesc, int) {
pd := pollcache.alloc() // ✅ pollCache로부터 pollDesc할당
lock(&pd.lock)
wg := pd.wg.Load()
if wg != pdNil && wg != pdReady {
throw("runtime: blocked write on free polldesc")
}
rg := pd.rg.Load()
if rg != pdNil && rg != pdReady {
throw("runtime: blocked read on free polldesc")
}
pd.fd = fd
if pd.fdseq.Load() == 0 {
// The value 0 is special in setEventErr, so don't use it.
pd.fdseq.Store(1)
}
pd.closing = false
pd.setEventErr(false, 0)
pd.rseq++
pd.rg.Store(pdNil)
pd.rd = 0
pd.wseq++
pd.wg.Store(pdNil)
pd.wd = 0
pd.self = pd
pd.publishInfo()
unlock(&pd.lock)
errno := netpollopen(fd, pd) // ✅ epoll에 fd등록
if errno != 0 {
pollcache.free(pd)
return nil, int(errno)
}
return pd, 0
}
// https://github.com/golang/go/blob/a10e42f219abb9c5b/src/runtime/netpoll_epoll.go#L52
func netpollopen(fd uintptr, pd *pollDesc) uintptr {
// ✅ fd에 in, out, rdhup(half-close) 이벤트를 엣지 트리거로 이벤트 등록
var ev syscall.EpollEvent
ev.Events = syscall.EPOLLIN | syscall.EPOLLOUT | syscall.EPOLLRDHUP | syscall.EPOLLET
tp := taggedPointerPack(unsafe.Pointer(pd), pd.fdseq.Load())
// ✅ 이벤트 데이터에 pd의 정보를 넣어서 전달
*(*taggedPointer)(unsafe.Pointer(&ev.Data)) = tp
return syscall.EpollCtl(epfd, syscall.EPOLL_CTL_ADD, int32(fd), &ev)
}
poll_runtime_pollOpen함수는 pollDesc를 생성 및 초기화하고 epoll에 이벤트를 등록합니다. 등록 후 pollDesc에 대한 정보 tp를 epoll이벤트 ev의 Data 필드에 저장합니다. 즉, 소켓에 대한 fd와 고루틴에 대한 대응 정보를 epoll에 저장합니다.
코드에서 초기화 부분을 생략하면 핵심은 EpollCtl로 트리거를 등록하는 부분입니다. 이 부분에서 fd에 대한 in, out, rdhup(half-close)이벤트를 엣지 트리거 모드로 등록합니다.
등록이 완료되면 poll_runtime_pollOpen이 리턴됩니다. 지금까지 살펴본 코드에 의하면 프로세스 생애에 하나의 epoll이 생성되고, 하나의 소켓에 대하여 netFD, FD, pollDesc가 유일함을 알 수 있습니다.
해당 동작을 golang 내에서 호출한 socket()으로부터 출발하면 다음과 같이 요약할 수 있습니다.

길게 golang net.Listen을 호출했을 때 발생하는 동작을 추적해봤습니다. 이때 진행되는 과정을 간단하게 요약하면 다음과 같습니다.
- net.Listen을 호출하면 차례대로 socket(), bind(), listen()이 호출됩니다.
- socket()으로부터 생성된 fd를 이용하여, netFD를 만들고 netFD안의 FD, pollDesc를 초기화합니다.
- pollDesc가 초기화될 때 runtime_pollServerInit이 프로세스 주기 내 최초 한 번만 실행됩니다.
- runtime_pollServerInit에서 epoll을 생성하고 epoll fd를 저장합니다. 또한 epoll 중단 시그널용 파이프를 생성하고 읽기 fd를 epoll에 등록합니다.
- 등록이 완료되면 runtime_pollOpen로 netFD의 소켓 fd인 sysfd를 epoll ctl로 이벤트를 등록합니다. 이때 in, out, half-close를 엣지 트리거로 등록합니다.
- 동작 완료 후 *TCPListener를 리턴합니다.
즉, net.Listen 과정에서 epoll을 생성하고 소켓 fd를 epoll에 등록하는 부분이 golang에서 netpoll에 구현되어 있다는 것을 알았습니다. 다음에는 listener 소켓 fd에서 이벤트가 발생했을 때 어떻게 처리하게 되는지 탐색해보겠습니다.
참고자료
golang - Go의 netpoller 구현 설명 - jacking75
Golang netpoll源码分析-腾讯云开发者社区-腾讯云
| [源码解读epoll内核机制 - Gityuan博客 | 袁辉辉的技术博客](https://gityuan.com/2019/01/06/linux-epoll/) |
Leave a comment