In traditional network programming, synchronous blocking was synonymous with poor performance. A single context switch could cost around 3 microseconds of CPU time. Although asynchronous, non-blocking models based on epoll
improved performance, the callback-based programming style felt unnatural and hard to follow. The resulting code was often difficult to understand.
The rise of Golang brought coroutine-based programming to a new level. It combines the simplicity and clarity of synchronous programming with the efficiency of coroutines working alongside epoll
under the hood—eliminating the high cost of thread switching. In short, it’s both easy to use and performs well.
Today, we’re going to dive deep into the net package provided by Golang itself, to understand how it manages to deliver on its promises.
I. Golang net usage
Given that not everyone reading this may have experience with Golang, let’s start with a simple example of a Golang service using the official net package. To keep things clear, I’ll only be showing the core code.
package main
import "net"
func main() {
// Create a listener
listener, _ := net.Listen("tcp", "127.0.0.1:9008")
for {
// Accept incoming connections
conn, err := listener.Accept()
// Handle each connection in a new goroutine
go process(conn)
}
}
func process(conn net.Conn) {
defer conn.Close() // Close connection when done
var buf [1024]byte
len, err := conn.Read(buf[:]) // Read data from connection
_, err = conn.Write([]byte("I am server!")) // Send response
...
}
In this sample service program, we first use net.Listen
to listen to a local port, 9008 in this case. Then we call Accept
to receive and handle the connection. If a connection request is received, we use go
process to launch a coroutine for handling it. During the connection handling, I demonstrate both read and write operations (Read and Write).
At first glance, this service program looks like a traditional synchronous model. Operations like Accept
, Read
, and Write
all seem to “block” the current coroutine. For instance, with the Read
function, if the server calls it before the client data arrives, Read
will not return, effectively parking the current coroutine. Only once data arrives does Read
return, allowing the processing coroutine to resume.
If you were to write similar server code in other languages, such as C or Java, you’d be in a world of hurt. That’s because each synchronous Accept
, Read
, and Write
operation would block your current thread, leading to a lot of wasted CPU cycles due to thread context switching.
Yet, in Golang, this kind of code performs impressively well. Why is that? Well, let’s keep reading to find out.
II. The Underlying Process of Listen
In more traditional languages like C or Java, the listen
function is typically implemented as a direct call to the kernel’s listen system call. If you’re thinking that’s what the Listen function does in Golang’s net package, think again.
Unlike its counterparts in other language, Golang’s net Listen does several things:
- Creates a
socket
and sets it to non-blocking bind
and listens to a local port- Calls
listen
to start listening - Uses
epoll_create
to create anepoll
object - Uses
epoll_etl
to add the listening sockets toepoll
, waiting for a connection
One call to Golang’s Listen is equivalent to multiple calls to functions like socket
, bind
, listen
, epoll_create
, epoll_etl
, etc. in C. It’s a highly abstracted operation, hiding a lot of the underlying implementation details from the programmer.
But don’t just take my word for it-let’s take a peek under the hood.
The Listen function’s entry point can be found in the net/dial.go
file in Golang’s source code. Let’s delve deeper into the details.
2.1 Entry Point for Listen
Unlike the detailed dissections often associated with source code reviews, our journey here requires an understanding of the broader picture rather than a line-by-line analysis.
// src/net/dial.go
func Listen(network, address string) (Listener, error) {
var lc ListenConfig
return lc.Listen(context.Background(), network, address)
}
This is an entry point, the next will go through the Listen
function of ListenConfig
. The Listen
function in ListenConfig
will determine if it is a tcp type, and go through the listenTCP
function under sysListener
. Then, after two or three function jump, will go to socket function in net/sock_posix.go
// net/sock_posix.go
func socket(ctx context.Context, net string, family, ...) (fd *netFD, err error) {
s, err := sysSocket(family, sotype, proto)
...
if laddr != nil && raddr == nil {
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
}
...
}
}
2.2 Creating the socket
sysSocket
differs from other languages by doing socket creation, binding, and listening all in one go:
// src/net/sys_cloexec.go
func sysSocket(family, sotype, proto int) (int, error) {
// See ../syscall/exec_unix.go for description of ForkLock.
syscall.ForkLock.RLock()
s, err := socketFunc(family, sotype, proto)
if err == nil {
syscall.CloseOnExec(s)
}
syscall.ForkLock.RUnlock()
if err != nil {
return -1, os.NewSyscallError("socket", err)
}
if err = syscall.SetNonblock(s, true); err != nil {
poll.CloseFunc(s)
return -1, os.NewSyscallError("setnonblock", err)
}
return s, nil
}
Within sysSocket
, the invoked socketFunc
is essentially a socket
system call.
// net/hook_unix.go
var (
// Placeholders for socket system calls.
socketFunc func(int, int, int) (int, error) = syscall.Socket
connectFunc func(int, syscall.Sockaddr) error = syscall.Connect
listenFunc func(int, int) error = syscall.Listen
getsockoptIntFunc func(int, int, int) (int, error) = syscall.GetsockoptInt
)
Once the socket
is created, syscall.SetNoblcok
is employed to switch the socket into non-blocking mode.
// src/syscall/exec_unix.go
func SetNonblock(fd int, nonblocking bool) (err error) {
flag, err := fcntl(fd, F_GETFL, 0)
if err != nil {
return err
}
if nonblocking {
flag |= O_NONBLOCK
} else {
flag &^= O_NONBLOCK
}
_, err = fcntl(fd, F_SETFL, flag)
return err
}
2.3 Binding and Listening
Now, inside listenStream
, we bind
and listen
using syscalls:
// net/sock_posix.go
func (fd *netFD) listenStream(ctx context.Context, laddr sockaddr, ...) error {
...
if err = syscall.Bind(fd.pfd.Sysfd, lsa); err != nil {
return os.NewSyscallError("bind", err)
}
if err = listenFunc(fd.pfd.Sysfd, backlog); err != nil {
return os.NewSyscallError("listen", err)
}
if err = fd.init(); err != nil {
return err
}
lsa, _ = syscall.Getsockname(fd.pfd.Sysfd)
fd.setAddr(fd.addrFunc()(lsa), nil)
return nil
}
listenFunc
operates as a macro, pointing directly to the syscall.Listen
system call.
// net/hook_unix.go
var (
socketFunc func(int, int, int) (int, error) = syscall.Socket
connectFunc func(int, syscall.Sockaddr) error = syscall.Connect
listenFunc func(int, int) error = syscall.Listen
getsockoptIntFunc func(int, int, int) (int, error) = syscall.GetsockoptInt
)
2.4 Creating and Initializing epoll
At the fd.init
line of code. After several function call expansions, the creation of the epoll
object is executed. This step also adds the socket
handle-which is currently in a listening state-to the epoll
object for network event management.
// src/internal/poll/fd_poll_runtime.go
func (pd *pollDesc) init(fd *FD) error {
serverInit.Do(runtime_pollServerInit)
ctx, errno := runtime_pollOpen(uintptr(fd.Sysfd))
if errno != 0 {
return errnoErr(syscall.Errno(errno))
}
pd.runtimeCtx = ctx
return nil
}
The serverInit.Do
function is deployed to ensure that the function passed as a parameter only executes once. We won’t go into too much detail about this. The parameter, runtime_pollServerInit
, is a call to the poll_runtime_pollServerInit
function belonging to the runtime package. You can find its source code within runtime/netpoll.go.
// src/runtime/netpoll.go
//go:linkname poll_runtime_pollServerInit internal/poll.runtime_pollServerInit
func poll_runtime_pollServerInit() {
netpollGenericInit()
}
This function eventually triggers the execution of netpollGenericInit
, where the epoll
object is created.
// src/runtime/netpoll_epoll.go
func netpollinit() {
var errno uintptr
epfd, errno = syscall.EpollCreate1(syscall.EPOLL_CLOEXEC)
...
}
runtime_pollOpen
accepts the file descriptor of the pre-listened socket
as its parameter. Within this function, the descriptor is added to the epoll
object.
// runtime/netpoll.go
//go:linkname poll_runtime_pollOpen internal/poll.runtime_pollOpen
func poll_runtime_pollOpen(fd uintptr) (*pollDesc, int) {
...
errno := netpollopen(fd, pd)
if errno != 0 {
pollcache.free(pd)
return nil, int(errno)
}
return pd, 0
}
// runtime/netpoll_epoll.go
func netpollopen(fd uintptr, pd *pollDesc) uintptr {
var ev syscall.EpollEvent
ev.Events = syscall.EPOLLIN | syscall.EPOLLOUT | syscall.EPOLLRDHUP | syscall.EPOLLET
*(**pollDesc)(unsafe.Pointer(&ev.Data)) = pd
return syscall.EpollCtl(epfd, syscall.EPOLL_CTL_ADD, int32(fd), &ev)
}
III. Accept Process
After the server completes the Listen operation, it calls Accept. This function mainly does three things:
- Calls the
accept
system call to receive a connection - If no connection has arrived, it blocks the current goroutine
- When a new connection arrives, it adds it to
epoll
for management and then returns
With step-by-step debugging in Go, you can observe that it goes into the Accept
method of TCPListener
:
// net/tcpsock.go
func (l *TCPListener) Accept() (Conn, error) {
if !l.ok() {
return nil, syscall.EINVAL
}
c, err := l.accept()
if err != nil {
return nil, &OpError{Op: "accept", Net: l.fd.net, Source: nil, Addr: l.fd.laddr, Err: err}
}
return c, nil
}
The three steps we mentions above all done in netFD
of accept
function.
// net/tcpsock_posix.go
func (ln *TCPListener) accept() (*TCPConn, error) {
fd, err := ln.fd.accept()
if err != nil {
return nil, err
}
return newTCPConn(fd, ln.lc.KeepAlive, nil), nil
}
// net/fd_unix.go
func (fd *netFD) accept() (netfd *netFD, err error) {
d, rsa, errcall, err := fd.pfd.Accept()
if err != nil {
if errcall != "" {
err = wrapSyscallError(errcall, err)
}
return nil, err
}
if netfd, err = newFD(d, fd.family, fd.sotype, fd.net); err != nil {
poll.CloseFunc(d)
return nil, err
}
if err = netfd.init(); err != nil {
netfd.Close()
return nil, err
}
...
}
Next up, let’s dissect each of these steps in detail.
3.1 Accepting a Connection
Through step-by-step debugging, we find that Accept
goes into the Accept
method of the FD
object. Here, it calls the OS-level accept system call:
// internal/poll/fd_unix.go
func (fd *FD) Accept() (int, syscall.Sockaddr, string, error) {
for {
s, rsa, errcall, err := accept(fd.Sysfd)
if err == nil {
return s, rsa, "", err
}
switch err {
case syscall.EINTR:
continue
case syscall.EAGAIN:
if fd.pd.pollable() {
if err = fd.pd.waitRead(fd.isFile); err == nil {
continue
}
}
case syscall.ECONNABORTED:
// This means that a socket on the listen
// queue was closed before we Accept()ed it;
// it's a silly error, so try again.
continue
}
return -1, nil, errcall, err
}
...
}
The internal accept
method triggers the Linux kernel’s accept
system call (not expanded here). The goal is to get an incoming connection from the client. If received, it returns it.
3.2 Blocking the Current Goroutine
Now let’s consider the situation where accept
is called but no client connections have yet arrived.
In this case, the accept
system call will return syscall.EAGAIN
. Go handles this status by blocking the current goroutine. The key code:
// internal/poll/fd_poll_runtime.go
func (pd *pollDesc) waitRead(isFile bool) error {
return pd.wait('r', isFile)
}
func (pd *pollDesc) wait(mode int, isFile bool) error {
if pd.runtimeCtx == 0 {
return errors.New("waiting for unsupported file type")
}
res := runtime_pollWait(pd.runtimeCtx, mode)
return convertErr(res, isFile)
}
The runtime_pollWait
function is found in runtime/netpoll.go
, where the goroutine is blocked using gopark
:
// runtime/netpoll.go
//go:linkname poll_runtime_pollWait internal/poll.runtime_pollWait
func poll_runtime_pollWait(pd *pollDesc, mode int) int {
...
for !netpollblock(pd, int32(mode), false) {
errcode = netpollcheckerr(pd, int32(mode))
if errcode != pollNoError {
return errcode
}
// Can happen if timeout has fired and unblocked us,
// but before we had a chance to run, timeout has been reset.
// Pretend it has not happened and retry.
}
return pollNoError
}
func netpollblock(pd *pollDesc, mode int32, waitio bool) bool {
...
// need to recheck error states after setting gpp to pdWait
// this is necessary because runtime_pollUnblock/runtime_pollSetDeadline/deadlineimpl
// do the opposite: store to closing/rd/wd, publishInfo, load of rg/wg
if waitio || netpollcheckerr(pd, mode) == pollNoError {
gopark(netpollblockcommit, unsafe.Pointer(gpp), waitReasonIOWait, traceEvGoBlockNet, 5)
}
}
The gopark
function is the internal Go runtime’s way to block a goroutine.
3.3 Adding the New Connection to epoll
Now let’s consider when a client connection has already arrived. In this case, fd.pfd.Accept
returns the new connection, and Go adds this connection to epoll for efficient event management:
// net/fd_unix.go
func (fd *netFD) accept() (netfd *netFD, err error) {
d, rsa, errcall, err := fd.pfd.Accept()
if err != nil {
if errcall != "" {
err = wrapSyscallError(errcall, err)
}
return nil, err
}
if netfd, err = newFD(d, fd.family, fd.sotype, fd.net); err != nil {
poll.CloseFunc(d)
return nil, err
}
if err = netfd.init(); err != nil {
netfd.Close()
return nil, err
}
...
}
Let’s look at netfd.init
:
// internal/poll/fd_poll_runtime.go
func (pd *pollDesc) init(fd *FD) error {
ctx, errno := runtime_pollOpen(uintptr(fd.Sysfd))
...
}
We already discussed runtime_pollOpen
in Section 2.4 earlier. It adds the file descriptor to the epoll
object:
// runtime/netpoll.go
func poll_runtime_pollOpen(fd uintptr) (*pollDesc, int) {
...
errno := netpollopen(fd, pd)
if errno != 0 {
pollcache.free(pd)
return nil, int(errno)
}
return pd, 0
}
// runtime/netpoll_epoll.go
func netpollopen(fd uintptr, pd *pollDesc) uintptr {
var ev syscall.EpollEvent
ev.Events = syscall.EPOLLIN | syscall.EPOLLOUT | syscall.EPOLLRDHUP | syscall.EPOLLET
*(**pollDesc)(unsafe.Pointer(&ev.Data)) = pd
return syscall.EpollCtl(epfd, syscall.EPOLL_CTL_ADD, int32(fd), &ev)
}
IV. Internal Process of Read and Write
After a connection has been successfully accepted, the remaining task is to perform read and write operations on the connection.
4.1 Internal Process of Read
Let’s look at the detailed code:
// net/net.go
func (c *conn) Read(b []byte) (int, error) {
...
n, err := c.fd.Read(b)
}
The Read function calls the Read method of the FD
(file descriptor). Internally, it invokes the system read call to read data. If the data hasn’t arrived yet, it blocks the current goroutine:
// internal/poll/fd_unix.go
func (fd *FD) Read(p []byte) (int, error) {
...
for {
n, err := ignoringEINTRIO(syscall.Read, fd.Sysfd, p)
if err != nil {
n = 0
if err == syscall.EAGAIN && fd.pd.pollable() {
if err = fd.pd.waitRead(fd.isFile); err == nil {
continue
}
}
}
err = fd.eofError(n, err)
return n, err
}
}
The waitRead
function blocks the current goroutine, just like we explained earlier in Section 3.2, so we won’t go into details again here.
4.2 Internal Process of Write
The overall process of Write is similar to that of Read. It first calls the write system call to send data. If the kernel’s send buffer is full, the goroutine blocks itself and waits until it becomes writable again.
The entry point for the source code is in net/net.go
:
// net/net.go
func (c *conn) Write(b []byte) (int, error) {
...
n, err := c.fd.Write(b)
...
}
// internal/poll/fd_unix.go
func (fd *FD) Write(p []byte) (int, error) {
...
for {
max := len(p)
if fd.IsStream && max-nn > maxRW {
max = nn + maxRW
}
n, err := ignoringEINTRIO(syscall.Write, fd.Sysfd, p[nn:max])
if n > 0 {
nn += n
}
if nn == len(p) {
return nn, err
}
if err == syscall.EAGAIN && fd.pd.pollable() {
if err = fd.pd.waitWrite(fd.isFile); err == nil {
continue
}
}
}
// internal/poll/fd_poll_runtime.go
func (pd *pollDesc) waitWrite(isFile bool) error {
return pd.wait('w', isFile)
}
After calling pd.wait
, the process continues just like in Section 3.2: it uses runtime_pollWait
to block the current goroutine:
// internal/poll/fd_poll_runtime.go
func (pd *pollDesc) wait(mode int, isFile bool) error {
...
res := runtime_pollWait(pd.runtimeCtx, mode)
return convertErr(res, isFile)
}
V. Goroutine Wake-up in Golang
In many of the previous steps we discussed, goroutine blocking is involved. For example, when using Accept
, if no new connection has arrived yet, or during Read
operations when the other side hasn’t sent any data—Golang will not let the goroutine hold onto the CPU. Instead, it blocks the goroutine.
So, when the awaited event becomes ready, how does the blocked goroutine get rescheduled? That’s probably a question you’re curious about.
In Go’s runtime, there is a monitoring routine called sysmon
, which is invoked during scheduling or system monitoring. It calls netpoll
, which in turn continuously calls epoll_wait
to check which file descriptors managed by the epoll
object have events that are ready to be handled. If it finds one, it wakes up the corresponding goroutine for execution.
Besides sysmon, there are a few other points in the runtime that can wake up goroutines, such as:
- startTheWorldWithSema
- findrunnable (used in the scheduler; it has both “top” and “stop” parts. The “stop” part may lead to blocking.)
- pollWork
But for simplicity, we’ll focus on sysmon as our entry point. sysmon
is a periodic system monitoring goroutine. Let’s look at its source code:
// src/runtime/proc.go
func sysmon() {
...
list := netpoll(0) // non-blocking - returns list of goroutines
...
}
It repeatedly calls netpoll
. Within netpoll
, it calls epollwait
to check for any network events:
// runime/netpoll_epoll.go
func netpoll(delay int64) gList {
...
retry:
n, errno := syscall.EpollWait(epfd, events[:], int32(len(events)), waitms)
if errno != 0 {
if errno != _EINTR {
println("runtime: epollwait on fd", epfd, "failed with", errno)
throw("runtime: netpoll failed")
}
// If a timed sleep was interrupted, just return to
// recalculate how long we should sleep now.
if waitms > 0 {
return gList{}
}
goto retry
}
var toRun gList
for i := int32(0); i < n; i++ {
ev := events[i]
if ev.Events == 0 {
continue
}
if *(**uintptr)(unsafe.Pointer(&ev.Data)) == &netpollBreakRd {
if ev.Events != syscall.EPOLLIN {
println("runtime: netpoll: break fd ready for", ev.Events)
throw("runtime: netpoll: break fd ready for something unexpected")
}
if delay != 0 {
// netpollBreak could be picked up by a
// nonblocking poll. Only read the byte
// if blocking.
var tmp [16]byte
read(int32(netpollBreakRd), noescape(unsafe.Pointer(&tmp[0])), int32(len(tmp)))
netpollWakeSig.Store(0)
}
continue
}
var mode int32
if ev.Events&(syscall.EPOLLIN|syscall.EPOLLRDHUP|syscall.EPOLLHUP|syscall.EPOLLERR) != 0 {
mode += 'r'
}
if ev.Events&(syscall.EPOLLOUT|syscall.EPOLLHUP|syscall.EPOLLERR) != 0 {
mode += 'w'
}
if mode != 0 {
pd := *(**pollDesc)(unsafe.Pointer(&ev.Data))
pd.setEventErr(ev.Events == syscall.EPOLLERR)
netpollready(&toRun, pd, mode)
}
}
return toRun
}
When epoll
returns, ev.data
contains the file descriptor of the ready network socket
. Based on the ready FD, the corresponding pollDesc
is retrieved. In netpollready, the related goroutine is pushed into the runnable queue to wait for scheduling.
// runime/netpoll.go
func netpollready(toRun *gList, pd *pollDesc, mode int32) {
var rg, wg *g
if mode == 'r' || mode == 'r'+'w' {
rg = netpollunblock(pd, 'r', true)
}
if mode == 'w' || mode == 'r'+'w' {
wg = netpollunblock(pd, 'w', true)
}
if rg != nil {
toRun.push(rg)
}
if wg != nil {
toRun.push(wg)
}
}
VI. In Conclusion
The advantage of synchronous programming is that it aligns with how people think in a straight, linear way. Code written in this style is easy to write and easy to understand. However, the downside is very poor performance, mainly because it leads to frequent thread context switches.
That’s why epoll
has become the primary model for network programming on Linux. Most modern and popular network frameworks across different languages are built on top of epoll
. The main difference lies in how each language or framework uses epoll
. While asynchronous, non-blocking models based on epoll
significantly improve performance, they usually rely on callback-based programming, which doesn’t align well with linear human thinking. As a result, such code tends to be harder to write and understand.
Golang introduces a new model for network programming. From the application’s point of view, it still looks like synchronous code. But under the hood, Go combines goroutines and epoll
, avoiding the high cost of thread switching. Instead of blocking user threads, it uses lightweight goroutine switching, which has much lower overhead.