9 minute read

해당 아티클은 다음 수준의 지식을 요구합니다.

  • 고루틴을 사용해본 경험

다음 실행 환경에서 동작합니다.

  • wsl2 Ubuntu-22.04
  • go1.23 linux/amd64

이전 아티클에서 golang의 net 패키지를 이용하여 아주 기본적인 형태의 tcp echo 서버를 구현했습니다. net 패키지의 Listener와 Conn 인터페이스를 활용하여 io처리를 해보았습니다. 리눅스 환경에서 Listener와 Conn 인터페이스는 POSIX의 syscall을 추상화하여 소켓에 대한 R/W를 진행한다는 것을 확인했습니다. 이번에는 위의 방식을 golang에서 unix 패키지를 이용하여 직접 호출하는 방식으로 서버를 구현해보고 tcp 서버를 연결하는 과정을 좀 더 깊이 들어가 보겠습니다. 최근에는 이런 방식을 직접적으로 활용하여 소켓 프로그래밍을 하진 않지만 후에 golang net 패키지에서 사용하는 netpoller의 동작을 이해하는 데 도움이 되기 때문에 살펴보는 게 좋습니다.


echo 서버의 기존 코드와 비교

기존 코드는 다음과 같습니다.

package main

import (
	"fmt"
	"net"
)

func main() {
	// 1. listen으로 서버 호스팅
	listener, err := net.Listen("tcp", ":4999")
	if err != nil {
		fmt.Println("listen error", err)
		return
	}

	// 2. accept으로 클라이언트 요청 수락 대기
	for {
		conn, err := listener.Accept()
		if err != nil {
			fmt.Println("accept error", err)
			continue
		}

		// 3. 고루틴으로 클라이언트 통신에 대한 비동기 요청 처리
		go func(conn net.Conn) {
			b := make([]byte, 1024)
			for {
				r, err := conn.Read(b)
				if err != nil {
					fmt.Println("read error", err)
					break
				}

				_, err = conn.Write(b[:r])
				if err != nil {
					fmt.Println("write error", err)
					break
				}
			}
		}(conn)
	}
}

위의 코드를 POSIX syscall을 직접 호출하는 방식으로 변경해보겠습니다. POSIX syscall로 소켓을 제어하고 tcp 연결에 대한 세부 설정을 할 수 있습니다. 전체적인 코드는 다음과 같습니다.

package main

import (
	"fmt"

	"golang.org/x/sys/unix"
)

func main() {
	// 1. 서버측 소켓 생성 후 소켓에 대한 file discriptor 소유
	fd, err := unix.Socket(unix.AF_INET, unix.SOCK_STREAM, 0)
	if err != nil {
		fmt.Println("socket error", err)
		return
	}

	// 2. setsopckopt으로 fd에 소켓 옵션 지정
	// 여기서는 REUSEADDR 플래그 활성화
	if err := unix.SetsockoptInt(fd, unix.SOL_SOCKET, unix.SO_REUSEADDR, 1); err != nil {
		fmt.Println("set sock opt error", err)
		return
	}

	// // 3. 서버측 fd에 대한 blocking 설정
	// if err := unix.SetNonblock(fd, true); err != nil {
	// 	fmt.Println("set non-block error", err)
	// 	return
	// }

	// 4. ipv4기반 주소 생성 및 서버측 소켓과 주소 바인딩
	addr := &unix.SockaddrInet4{}
	addr.Addr = [4]byte{0, 0, 0, 0}
	addr.Port = 4999
	if err := unix.Bind(fd, addr); err != nil {
		fmt.Println("bind error", err)
		return
	}

	// 5. backlog queue 사이즈 설정 및 서버 호스팅
	queueSize := 1024
	if err := unix.Listen(fd, queueSize); err != nil {
		fmt.Println("listen error", err)
		return
	}

	for {
		// 6. accept를 호출하여 클라이언트 연결 대기
		// Accept4호출 이유는 클라이언트측 소켓에 옵션을 부여하여 생성할 수 있기 때문
		// nfd, _, err := unix.Accept4(fd, syscall.SOCK_NONBLOCK|syscall.SOCK_CLOEXEC)
		nfd, _, err := unix.Accept4(fd, unix.SOCK_CLOEXEC)
		if err != nil {
			fmt.Println("accept4 error", err)
			continue
		}

		// 7. accept의 리턴으로 받은 클라이언트측 소켓에 대한 file discriptor를 통해
		// r/w를 진행
		go func(nfd int) {
			b := make([]byte, 1024)
			for {
				if err := logic(nfd, b); err != nil {
					fmt.Println("fd error", err)
					unix.Close(int(nfd))
					break
				}
			}
		}(nfd)
	}
}

func logic(fd int, b []byte) error {
	r, err := unix.Read(fd, b)
	if err != nil {
		return err
	}

	if r == 0 {
		return fmt.Errorf("read EOF")
	}

	_, err = unix.Write(fd, b[:r])
	if err != nil {
		return err
	}
	return nil
}

해당 코드를 단계 별로 분석해보겠습니다.


1. Socket

먼저 socket()부분입니다.

	// ...
	// 1. 서버측 소켓 생성 후 소켓에 대한 file discriptor 소유
	fd, err := unix.Socket(unix.AF_INET, unix.SOCK_STREAM, 0)
	if err != nil {
		fmt.Println("socket error", err)
		return
	}
	// ...

unix.Socket()함수는 POSIX의 다음 시그니처에 대응됩니다. 다음 명령을 입력하면 명령에 대한 세부 내용을 확인할 수 있습니다.

man socket

# ...
# SYNOPSIS
#        #include <sys/types.h>          /* See NOTES */
#        #include <sys/socket.h>
# 
#        int socket(int domain, int type, int protocol);
# ...

해당 설명을 간단하게 요약해보면 socket()함수는 통신을 위한 엔드포인트를 생성하고 해당 엔드포인트를 참조하는 file discriptor라는 값을 반환한다고 합니다.

여기서 file discriptor라는 설명이 등장합니다. 프로세스에서 파일을 접근할 때 파일을 참조하기 위한 테이블이 존재합니다. 이를 file discriptor table이라고 합니다. 이 file discriptor table은 프로세스마다 가지고 있는 테이블입니다. 해당 테이블의 index가 file discriptor(fd)라는 프로세스에서 파일을 참조하기 위한 정수이고 index에 위치하는 value는 table entry라는 파일에 대한 정보입니다. socket()을 호출하면 소켓을 file discriptor table에 등록하고 해당 소켓에 대한 fd를 리턴합니다.

대략적인 동작을 살펴봤으니 다음은 입력으로 전달하는 상수입니다.

먼저 socket()은 int domain을 주어야 하는데 이 domain은 tcp 서버 연결에서 주소 체계를 의미합니다. tcp 소켓 서버에서 유의미한 domain값으로 크게 다음이 있습니다.

  • AF_INET: ipv4
  • AF_INET6: ipv6

다음 인자로 type을 전달합니다. type은 소켓의 타입을 나타내는데 tcp 소켓 서버에서 유의미한 type은 다음이 있습니다.

  • SOCK_STREAM: tcp
  • SOCK_DGRAM: udp(udp인 경우 다음으로 사용합니다)

마지막으로 protocol입니다. 소켓이 사용할 프로토콜을 지정합니다. 0으로 지정하면 시스템이 알아서 결정해줍니다.

golang 예시 코드에서 다음과 같이 호출했습니다.

fd, err := unix.Socket(unix.AF_INET, unix.SOCK_STREAM, 0)

2. SetsockoptInt

socket에 대한 설정을 추가하는 부분입니다.

	// ...
	if err := unix.SetsockoptInt(fd, unix.SOL_SOCKET, unix.SO_REUSEADDR, 1); err != nil {
		fmt.Println("set sock opt error", err)
		return
	}

해당 부분에 대한 설명은 다음과 같습니다.

man setsockopt

# #include <sys/types.h>          /* See NOTES */
# #include <sys/socket.h>
#
# int getsockopt(int sockfd, int level, int optname,
#               void *optval, socklen_t *optlen);
# int setsockopt(int sockfd, int level, int optname,
#               const void *optval, socklen_t optlen);
# ...

특정 소켓에 대한 옵션을 설정합니다. 이 옵션은 소켓 레벨이나 다른 프로토콜 레벨에 대한 옵션을 설정합니다. 레벨에는 일반적으로 SOL_SOCKET, IPPROTO_TCP의 값을 갖습니다.

설정할 수 있는 옵션으로 여러 옵션이 있는데, 그 중 많이 사용하는 옵션은 다음과 같습니다.

  • SO_KEEPALIVE: 소켓 연결 유지를 위한 메세지 전송 활성화
  • SO_RCVBUF: 수신 버퍼 크기
  • SO_SNDBUF: 송신 버퍼 크기
  • SO_REUSEADDR: 소켓에 사용 중인 로컬 주소에 바인딩 될 수 있도록 함
  • SO_REUSEPORT: 여러 소켓이 동일 포트에 대한 바인딩 허용
  • SO_LINGER: 연결이 닫힐 때 소켓 버퍼에 있는 데이터 처리에 대한 옵션

위의 코드에서 SO_REUSEADDR을 활성화하여 디버깅 실행 후 서버를 종료했을 때 address already in use 에러를 피하기 위해 사용합니다. 해당 옵션을 활성화하면 TIME_WAIT 상태에 있는 소켓 로컬 주소와 포트를 재사용할 수 있습니다.


3. SetNonblock

주석으로 명시된 다음 부분입니다.

	// ...
	// // 2. 서버측 fd에 대한 blocking 설정
	// if err := unix.SetNonblock(fd, true); err != nil {
	// 	fmt.Println("set non-block error", err)
	// 	return
	// }
	// ...

여기서 unix.SetNonblock이라는 함수를 호출하는데 내부 구현을 들어가보면 다음 코드로 되어 있습니다.

// https://github.com/golang/go/blob/a10e42f219abb9c5b/src/syscall/exec_unix.go#L105
func SetNonblock(fd int, nonblocking bool) (err error) {
	flag, err := fcntl(fd, F_GETFL, 0) // fcntl로 파일의 설정 가져오기
	if err != nil {
		return err
	}
	if (flag&O_NONBLOCK != 0) == nonblocking { // 파일 설정이 이미 전달한 설정이면 리턴
		return nil
	}
	if nonblocking { // SetNonblock(fd, true)인 경우 flag에 O_NONBLOCK 활성화, 아니면 비활성화
		flag |= O_NONBLOCK
	} else {
		flag &^= O_NONBLOCK
	}
	_, err = fcntl(fd, F_SETFL, flag) // flag 변경사항 반영
	return err
}

해당 코드를 살펴보면 fcntl을 호출합니다. fcntl에 대한 설명을 확인해보겠습니다.

man fcntl

# SYNOPSIS
#        #include <unistd.h>
#        #include <fcntl.h>
# 
#        int fcntl(int fd, int cmd, ... /* arg */ );

열려있는 파일에 대한 fd를 통하여 어떤 명령을 수행합니다. 여기서 cmd로 F_GETFL을 통해 파일의 상태 플래그를 받고 F_SETFL을 통해 파일의 상태 플래그를 적용합니다. SetNonblock함수는 fcntl로 파일의 논블로킹 상태 변경을 트리거합니다.

블로킹/논블로킹에 대한 서버 동작은 다음과 같습니다.

  • 블로킹인 경우: 어떤 io 작업이 완료될 때까지 io 작업 호출이 대기합니다.
  • 논블로킹인 경우: 어떤 io 작업이 대기해야 할 상황이면 io 작업 호출이 특정 에러를 리턴합니다. 이때 에러는 EAGAIN/EWOULDBLOCK입니다.

예를 들어 accept() syscall인 경우

  • 블로킹인 경우 클라이언트 요청이 수락되거나 서버에서 에러가 발생할 때까지 accept()에서 블로킹됩니다.
  • 논블로킹인 경우 클라이언트 요청이 수락되지 않아 서버에서 당장 처리할 수 없는 경우에 accept()에서 EAGAIN/EWOULDBLOCK에러가 발생합니다. 이 경우 다시 accept()가 올바르게 호출될 때까지 반복하도록 코드를 작성하거나 io multiplexing을 이용해야 합니다. io multiplexing은 이후 아티클에서 다뤄보겠습니다.

4. Bind

다음은 Bind입니다.

	// ...
	addr := &unix.SockaddrInet4{}
	addr.Addr = [4]byte{0, 0, 0, 0}
	addr.Port = 4999
	if err := unix.Bind(fd, addr); err != nil {
		fmt.Println("bind error", err)
		return
	}
	// ...

bind()에 대한 설명을 확인해보겠습니다.

man bind

# SYNOPSIS
#        #include <sys/types.h>          /* See NOTES */
#        #include <sys/socket.h>
# 
#        int bind(int sockfd, const struct sockaddr *addr,
#                 socklen_t addrlen);

socket()으로 생성된 주소가 할당되지 않은 소켓에 대하여 bind()는 fd에 전달한 addr을 통해 주소를 할당합니다. 여기서 sockaddr이라는 구조체를 bind의 인자로 등록하고 socklen_t를 통해 주소의 길이를 같이 전달합니다. golang의 경우 Bind의 시그니처는 다음과 같습니다.

// https://github.com/golang/go/blob/a10e42f219abb9c5b/src/syscall/syscall_unix.go#L285
func Bind(fd int, sa Sockaddr) (err error)

여기서 Sockaddr 인터페이스는 다음과 같습니다.

// https://github.com/golang/go/blob/a10e42f219abb9c5b/src/syscall/syscall_unix.go#L263
// Sockaddr represents a socket address.
type Sockaddr interface {
	sockaddr() (ptr unsafe.Pointer, len _Socklen, err error) // lowercase; only we can define Sockaddrs
}

unix.SockaddrInet4 타입은 Sockaddr 인터페이스이므로 알아서 포인터와 socket 길이에 대한 데이터를 실제 bind() syscall에 전달합니다. 코드에는 0.0.0.0:4999 주소에 소켓은 연결하라는 옵션을 전달합니다.

	addr := &unix.SockaddrInet4{}
	addr.Addr = [4]byte{0, 0, 0, 0}
	addr.Port = 4999

unix.SockaddrInet6과 같은 주소 타입을 활용할 수 있습니다.


5. Listen

다음은 Listen입니다.

	// ...
	queueSize := 1024
	if err := unix.Listen(fd, queueSize); err != nil {
		fmt.Println("listen error", err)
		return
	}
	// ...

listen()의 설명을 확인해보겠습니다.

man listen

# SYNOPSIS
#        #include <sys/types.h>          /* See NOTES */
#        #include <sys/socket.h>
# 
#        int listen(int sockfd, int backlog);

listen() 함수의 설명을 살펴보면 sockfd가 참조하는 소켓을 accept()를 이용하여 연결 요청을 수락하는데 사용하는 것으로 변경한다고 합니다.

해당 함수의 인자로 backlog라는 int값을 전달합니다. 해당 인자는 sockfd에 대기 중인 큐의 최대 길이를 정의합니다. 해당 큐를 backlog queue라고 합니다. 3way handshake를 완료했으나 애플리케이션에서 accept()를 호출하지 않은 클라이언트 연결이 해당 큐에 저장됩니다. 이 큐의 사이즈를 조절하는 값이 listen()의 backlog인자입니다.


6. Accept

다음은 Accept입니다.

	// ...
	for {
		// nfd, _, err := unix.Accept4(fd, syscall.SOCK_NONBLOCK|syscall.SOCK_CLOEXEC)
		nfd, _, err := unix.Accept4(fd, unix.SOCK_CLOEXEC)
		if err != nil {
			fmt.Println("accept4 error", err)
			continue
		}
	// ...

여기서는 Accept4를 호출하고 있습니다. accept을 하는 과정에서 호출하는 함수가 accept()와 accept4()가 있습니다. 이 둘의 차이점은 뒤에 flag의 유무입니다.

man accept

# SYNOPSIS
#        #include <sys/types.h>          /* See NOTES */
#        #include <sys/socket.h>
# 
#        int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
# 
#        #define _GNU_SOURCE             /* See feature_test_macros(7) */
#        #include <sys/socket.h>
# 
#        int accept4(int sockfd, struct sockaddr *addr,
#                    socklen_t *addrlen, int flags);

accept() 함수는 3way handshake를 마친 연결이 대기하는 backlog queue에서 연결을 가져와 소켓을 생성하고 소켓에 대한 fd와 소켓의 주소, 주소의 길이를 반환합니다. accept4()함수는 여기에 flag를 추가로 전달하는데, 이 flag는 소켓이 생성되는 시점에 적용되어야 할 소켓의 상태를 반영하는 flag입니다. flag에 0을 전달하면 accept4는 accept와 같습니다.

accept4의 flag로 전달하는 인자는 일반적으로 SOCK_NONBLOCK과 SOCK_CLOEXEC입니다.

  • SOCK_NONBLOCK: 소켓이 생성될 때 논블로킹 옵션이 적용된 상태가 됩니다.
  • SOCK_CLOEXEC: 부모 프로세스의 fd가 자식 프로세스의 fd로 상속되어 유지되는 경우를 방지합니다. 자식 프로세스에서 fd가 자동으로 닫히도록 합니다.

7. Read & Write

마지막은 read와 write부분입니다.

		// ...
		go func(nfd int) {
			b := make([]byte, 1024)
			for {
				if err := logic(nfd, b); err != nil {
					fmt.Println("fd error", err)
					unix.Close(int(nfd))
					break
				}
			}
		}(nfd)
	}
}

func logic(fd int, b []byte) error {
	r, err := unix.Read(fd, b)
	if err != nil {
		return err
	}

	if r == 0 {
		return fmt.Errorf("read EOF")
	}

	_, err = unix.Write(fd, b[:r])
	if err != nil {
		return err
	}
	return nil
}

이 부분은 기존 net 패키지의 Conn 인터페이스를 활용한 부분과 거의 유사합니다. 딱 한 가지 차이가 있는데 바로 read byte시 err가 없는데 읽은 바이트가 0으로 리턴된 경우입니다. 이 경우는 EOF로 처리해주어야 합니다.


golang net패키지로 추상화된 네트워크 연결을 위한 POSIX syscall을 golang의 unix 패키지를 활용하여 세부적인 사항을 구현해봤습니다. 물론 일반적인 경우에 unix 패키지를 직접 활용하여 네트워크 연결을 구현하는 경우는 없습니다. tcp 서버를 정밀하게 튜닝해야 하는 경우만 사용합니다. 그러나 netpoller를 이해하는데 기본적인 소켓 api를 알고 있으면 도움되기에 다뤄봤습니다.

주요한 부분을 정리해보면 다음과 같습니다.

  • file discriptor라는 값을 활용하여 소켓에 대하여 R/W를 진행할 수 있습니다.
  • 소켓에 대하여 논블로킹 설정을 활성화할 수 있습니다. 이때 논블로킹을 활성화하면 accept, read, write와 같은 syscall에 대하여 당장 syscall을 처리할 수 없는 경우 EAGAIN이나 EWOULDBLOCK으로 에러가 발생합니다.

unix로 변경했지만 여전히 고루틴이 커넥션 하나를 사용하고 있습니다. 이번에는 이렇게 커넥션마다 고루틴을 생성하는 방법이 아닌 io multiplexing, 입출력 다중화라는 방법으로 이를 해결해보겠습니다. epoll을 이용해봅시다.


코드입니다.

https://github.com/atgane/syncgo/blob/c32745b83be1ed4e879a4e46172aa2c6c860d5c0/03/main.go#L1

Leave a comment