OS/Common

TCP/IP 네트워크 스택 이해하기

장피디 2016. 5. 18. 17:42
반응형

출처 : http://d2.naver.com/helloworld/47667


정말로 좋은 글이 있어서 천천히 두고두고 공부할려고 파왔음...





TCP/IP 없는 인터넷 서비스는 상상할 수 없습니다. 우리가 개발하고 사용하는 모든 인터넷 서비스는 TCP/IP라는 튼튼한 토대에 기반하고 있습니다. 어떻게 네트워크를 통해 데이터가 오가는지를 이해하면, 튜닝 등을 통한 성능 개선이나 트러블 슈팅, 신기술 도입 등에 많은 도움이 됩니다.

이 글에서는 Linux 운영체제와 하드웨어 레이어에서의 데이터 흐름과 제어 흐름을 바탕으로 네트워크 스택에 대한 전반적인 작동 방식을 알아보겠습니다.

TCP/IP의 중요한 성질

데이터의 순서가 바뀌지 않으면서 데이터가 유실되지 않도록 가급적 빠르게 데이터를 보내려면 네트워크 프로토콜을 어떻게 설계해야 할까? TCP/IP는 이런 고민 아래 설계된 것이다. 다음은 스택을 이해하는데 필요한 TCP/IP의 중요한 성질이다.


TCP와 IP

엄밀히 말해 TCP와 IP는 서로 다른 레이어의 것이라 분리해서 이해하는 것이 옳지만, 이해의 편의상 여기서는이 장에서는 둘을 분리하지 않고 설명한다.


1. Connection oriented

두 개 엔드포인트(로컬, 리모트) 사이에 연결을 먼저 맺고 데이터를 주고받는다. 여기서 'TCP 연결 식별자'는 두 엔드포인트의 주소를 합친 것으로, <로컬 IP 주소, 로컬 포트번호, 리모트 IP 주소, 리모트 포트번호> 형태이다.

2. Bidirectional byte stream

양방향 데이터 통신을 하고, 바이트 스트림을 사용한다.

3. In-order delivery

송신자(sender)가 보낸 순서대로 수신자(receiver)가 데이터를 받는다. 이를 위해서는 데이터의 순서가 필요하다. 순서를 표시하기 위해 32-bit 정수 자료형을 사용한다.

4. Reliability through ACK

데이터를 송신하고 수신자로부터 ACK(데이터 받았음)를 받지 않으면, 송신자 TCP가 데이터를 재전송한다. 따라서 송신자 TCP는 수신자로부터 ACK를 받지 않은 데이터를 보관한다(buffer unacknowledged data).

5. Flow control

송신자는 수신자가 받을 수 있는 만큼 데이터를 전송한다. 수신자가 자신이 받을 수 있는 바이트 수 (사용하지 않은 버퍼 크기, receive window)를 송신자에게 전달한다. 송신자는 수신자 receive window가 허용하는 바이트 수만큼 데이터를 전송한다.

6. Congestion control

네트워크 정체를 방지하기 위해 receive window와 별도로 congestion window를 사용하는데 이는 네트워크에 유입되는 데이터양을 제한하기 위해서이다. Receive window와 마찬가지로 congestion window가 허용하는 바이트 수만큼 데이터를 전송하며 여기에는 TCP Vegas, Westwood, BIC, CUBIC 등 다양한 알고리즘이 있다. Flow control과 달리 송신자가 단독으로 구현한다.

데이터 전송

이름이 설명하듯, 네트워크 스택에는 여러 레이어(layer)가 있다. 어떤 레이어가 있는지는 그림 2에서 알 수 있다.

여러 레이어가 있지만, 크게 유저(user) 영역, 커널(kernel) 영역, 디바이스로(device) 영역으로 나눌 수 있다. 유저 영역과 커널 영역에서의 작업은 CPU가 수행한다. 이 유저 영역과 커널 영역은 디바이스 영역과 구별하기 위해 호스트(host)라고 부른다. 여기서 디바이스는 패킷을 송수신하는 NIC(Network Interface Card)이다. 흔히 부르는 랜카드보다 더 정확한 용어이다.

networkstack1

그림 1 데이터 전송 시 TCP/IP 네트워크 스택의 각 레이어 별 동작 과정

유저 영역부터 밑으로 내려가 보자. 우선 애플리케이션이 전송할 데이터를 생성하고(그림 1에서 User data 상자), write 시스템 콜을 호출해서 데이터를 보낸다. 소켓(그림 2에서 fd)은 이미 생성되어 연결되어 있다고 가정한다. 시스템 콜을 호출하면 커널 영역으로 전환된다.

Linux나 Unix를 포함한 POSIX 계열 운영체제는 소켓을 file descriptor로 애플리케이션에 노출한다. 이런 POSIX 계열의 운영체제에서 소켓은 파일의 한 종류다. 파일(file) 레이어는 단순한 검사만 하고 파일 구조체에 연결된 소켓 구조체를 사용해서 소켓 함수를 호출한다.

커널 소켓은 두 개의 버퍼를 가지고 있다. 송신용으로 준비한 send socket buffer, 수신용으로 준비한 receive socket buffer이다. Write 시스템 콜을 호출하면 유저 영역의 데이터가 커널 메모리로 복사되고, send socket buffer의 뒷부분에 추가된다. 순서대로 전송하기 위해서다. 그림에서 옅은 회식 상자가 이미 socket buffer에 존재하는 데이터를 의미한다. 이 다음으로 TCP를 호출한다.

소켓과 연결된 TCP Control Block(TCB) 구조체가 있다. TCB에는 TCP 연결 처리에 필요한 정보가 있다. TCB에 있는 데이터는 connection state(LISTEN, ESTABLISHED, TIME_WAIT 등), receive window, congestion window, sequence 번호, 재전송 타이머 등이다.

현재 TCP 상태가 데이터 전송을 허용하면 새로운 TCP segment, 즉 패킷을 생성한다. Flow control 같은 이유로 데이터 전송이 불가능하면 시스템 콜은 여기서 끝나고, 유저 모드로 돌아간다(즉, 애플리케이션으로 제어권이 넘어간다).

TCP segment에는 TCP 헤더와 페이로드(payload)가 있다. 페이로드에는 ACK를 받지 않은 send socket buffer에 있는 데이터가 담겨 있다. 페이로드의 최대 길이는 receive window, congestion window, MSS(Maximum Segment Size) 중 최대 값이다.

그리고 TCP checksum을 계산한다. 이 checksum 계산에는 pseudo 헤더 정보(IP 주소들, segment 길이, 프로토콜 번호)를 포함시킨다. 여기서 TCP 상태에 따라 패킷을 한 개 이상 전송할 수 있다.

사실 요즘의 네트워크 스택에서는 checksum offload 기술을 사용하기 때문에, 커널이 직접 TCP checksum을 계산하지 않고 대신 NIC가 checksum을 계산한다. 여기서는 설명의 편의를 위해 커널이 checksum을 계산한다고 가정한다.

생성된 TCP segment는 IP 레이어로 이동한다(내려 간다). IP 레이어에서는 TCP segment에 IP 헤더를 추가하고, IP routing을 한다. IP routing이란 목적지 IP 주소(destination IP)로 가기 위한 다음 장비의 IP 주소(next hop IP)를 찾는 과정을 말한다.

IP 레이어에서 IP 헤더 checksum을 계산하여 덧붙인 후, Ethernet 레이어로 데이터를 보낸다.

Ethernet 레이어는 ARP(Address Resolution Protocol)를 사용해서 next hop IP의 MAC 주소를 찾는다. 그리고 Ethernet 헤더를 패킷에 추가한다. Ethernet 헤더까지 붙으면 호스트의 패킷은 완성이다.

IP routing을 하면 그 결과물로 next hop IP와 해당 IP로 패킷 전송할 때 사용하는 인터페이스(transmit interface, 혹은 NIC)를 알게 된다. 따라서 transmit NIC의 드라이버를 호출한다.

만약 tcpdumpWireshark 같은 패킷 캡처 프로그램이 작동 중이면 커널은 패킷 데이터를 프로그램이 사용하는 메모리 버퍼에 복사한다. 수신도 마찬가지로 드라이버 바로 위에서 패킷을 캡처한다. 대개 traffic shaper 기능도 이 레이어에서 동작하도록 구현되어있다.

드라이버는 NIC 제조사가 정의한 드라이버-NIC 통신 규약에 따라 패킷 전송을 요청한다.

NIC는 패킷 전송 요청을 받고, 메인 메모리에 있는 패킷을 자신의 메모리로 복사하고, 네트워크 선으로 전송한다. 이때 Ethernet 표준에 따라 IFG(Inter-Frame Gap), preamble, 그리고 CRC를 패킷에 추가한다. IFG, preamble은 패킷의 시작을 판단하기 위해 사용하고(네트워킹 용어로는 framing), CRC는 데이터 보호를 위해 사용한다(TCP, IP checksum과 같은 용도이다). 패킷 전송은 Ethernet의 물리적 속도, 그리고 Ethernet flow control에 따라 전송할 수 있는 상황일 때 시작된다. 회의장에서 발언권을 얻고 말하는 것과 비슷하다.

NIC가 패킷을 전송할 때 NIC는 호스트 CPU에 인터럽트(interrupt)를 발생시킨다. 모든 인터럽트에는 인터럽트 번호가 있으며, 운영체제는 이 번호를 이용하여 이 인터럽트를 처리할 수 있는 적합한 드라이버를 찾는다. 드라이버는 인터럽트를 처리할 수 있는 함수(인터럽트 핸들러)를 드라이브가 가동되었을 때 운영체제에 등록해둔다. 운영체제가 핸들러를 호출하고, 핸들러는 전송된 패킷을 운영체제에 반환한다.

지금까지 설명한 것은 애플리케이션에서 쓰기를 하였을 때 데이터가 커널과 디바이스를 거쳐 전송되는 과정이다. 그런데 애플리케이션이 쓰기 요청을 직접적으로 하지 않아도 커널이 TCP를 호출해서 패킷을 전송하는 경우가 있다. 예를 들어 ACK을 받아 receive window가 늘어나면 socket buffer에 남아있는 데이터를 포함한 TCP segment를 생성하여 상대편에 전송한다.

데이터 수신

이제 어떻게 데이터를 수신하는지 알아보도록 하자. 패킷이 외부에서 도착했을 때 어떻게 작동하는지에 대한 것이다. 그림 3에서 네트워크 스택이 수신한 패킷을 처리하는 과정을 알 수 있다.

networkstack2

그림 2 데이터 수신 시 TCP/IP 네트워크 스택의 각 레이어 별 동작 과정

우선 NIC가 패킷을 자신의 메모리에 기록한다. CRC 검사로 패킷이 올바른지 검사하고, 호스트의 메모리버퍼로 전송한다. 이 버퍼는 드라이버가 커널에 요청하여 패킷 수신용으로 미리 할당한 메모리이고, 할당을 받은 후 드라이버는 NIC에 메모리 주소와 크기를 알려 준다. NIC가 패킷을 받았는데, 드라이버가 미리 할당해 놓은 호스트 메모리 버퍼가 없으면 NIC가 패킷을 버릴 수 있다 (packet drop).

패킷을 호스트 메모리로 전송한 후, NIC가 호스트운영체제에 인터럽트를 보낸다.

드라이버가 새로운 패킷을 보고 자신이 처리할 수 있는 패킷인지 검사한다. 여기까지는 제조사가 정의한 드라이버-NIC 통신 규약을 사용한다.

드라이버가 상위 레이어로 패킷을 전달하려면 운영체제가 이해할 수 있도록, 받은 패킷을 운영체제가 사용하는 패킷 구조체로 포장해야 한다. 예를 들어, Linux의 sk_buff, BSD 계열 커널의 mbuf, 그리고 Microsoft Windows의 NET_BUFFER_LIST가 운영체제의 패킷 구조체이다. 드라이버는 이렇게 포장한 패킷을 상위 레이어로 전달한다.

Ethernet 레이어에서도 패킷이 올바른지 검사하고, 상위 프로토콜(네트워크 프로토콜)을 찾는다(de-multiplex). 이때 Ethernet 헤더의 ethertype 값을 사용한다. IPv4 ethertype은 0x0800이다. Ethernet 헤더를 제거하고 IP 레이어로 패킷을 전달한다.

IP 레이어에서도 패킷이 올바른지 검사한다. IP 헤더 checksum을 확인하는 것이다. 논리적으로 여기서 IP routing을 해서 패킷을 로컬 장비가 처리해야 하는지, 아니면 다른 장비로 전달해야 하는지 판단한다. 로컬 장비가 처리해야 하는 패킷이면 IP 헤더의 proto 값을 보고 상위 프로토콜(트랜스포트 프로토콜)을 찾는다. TCP proto 값은 6이다. IP 헤더를 제거하고 TCP 레이어로 패킷을 전달한다.

하위 레이어에서와 마찬가지로 TCP 레이어에서도 패킷이 올바른지 검사한다. TCP checksum도 확인한다. 앞서 언급했듯이 요즘의 네트워크 스택에는 checksum offload 기술이 적용되어 있기 때문에 커널이 checksum을 직접 계산하지 않는다.

다음으로 패킷이 속하는 연결, 즉 TCP control block을 찾는다. 이때 패킷의 <소스 IP, 소스 port, 타깃 IP, 타깃 port>를 식별자로 사용한다. 연결을 찾으면 프로토콜을 수행해서 받은 패킷을 처리한다. 새로운 데이터를 받았다면, 데이터를 receive socket buffer에 추가한다. TCP 상태에 따라 새로운 TCP 패킷(예를 들어 ACK 패킷)을 전송할 수 있다. 여기까지 해서 TCP/IP 수신 패킷 처리 과정이 끝나게 된다.

Receive socket buffer 크기가 결국은 TCP의 receive window이다. 어느 지점까지는 receive window가 크면 TCP throughput이 증가한다. 예전에는 socket buffer 크기를 애플리케이션이나 운영체제 설정에서 조절하고는 했다. 최신 네트워크 스택은 receive socket buffer 크기, 즉 receive window를 자동으로 조절하는 기능을 가지고 있다.

이후 애플리케이션이 read 시스템 콜을 호출하면 커널 영역으로 전환되고, socket buffer에 있는 데이터를 유저 공간의 메모리로 복사해 간다. 복사한 데이터는 socket buffer에서 제거한다. 그리고 TCP를 호출한다. TCP는 socket buffer에 새로운 공간이 생겼기 때문에 receive window를 증가시킨다. 그리고 프로토콜 상태에 따라 패킷을 전송한다. 패킷 전송이 없으면 시스템 콜이 종료된다.

네트워크 스택 발전 방향

지금까지 설명한 네트워크 스택 레이어가 하는 일은 가장 기본적인 기능이다. 1990년대 초반의 네트워크 스택은 이보다 약간 더 기능이 많은 정도였다. 하지만 요즘의 최신 네트워크 스택은 더 많은 기능을 가지고 있고, 따라서 네트워크 스택 구현체의 복잡성도 증가했다.

최신의 네트워크 스택을 목적에 따라 구분해 보면 다음과 같다.

패킷 처리 과정 조작 기능

Netfilter(방화벽, NAT 등), traffic control 같은 기능이다. 기본 처리 흐름에 사용자가 제어할 수 있는 코드를 삽입해서 사용자 설정에 따라 다양한 효과를 낸다.

프로토콜 성능

주어진 네트워크 환경에서 TCP 프로토콜이 달성할 수 있는 throughput, latency, stability 등의 개선을 목표로 한다. 다양한 congestion control 알고리즘들과 SACK 같은 TCP 추가 기능이 대표적인 예이다. 프로토콜 개선 사항은 이 글의 범위 바깥이라 여기서는 다루지 않겠다.

패킷 처리 효율

한 장비가 패킷을 처리하는데 소요되는 CPU cycle, 메모리 사용량, 메모리 접근 수 등을 줄여서 초당 처리할 수 있는 최대 패킷 수를 개선하는 것을 목표로 한다. 장비 내부에서의 레이턴시(latency)를 줄이는 것을 포함한 여러 시도가 있었다. 스택 병렬처리, header prediction, zero-copy, single-copy, checksum offload, TSO, LRO, RSS 등 여러 가지가 있다.

스택 내부 제어 흐름(control flow)

이제 Linux 네트워크 스택의 내부 흐름을 좀더 깊게 살펴보자. 네트워크 스택이 아닌 서브시스템과 마찬가지로, 네트워크 스택은 기본적으로 이벤트 발생에 반응하는 event-driven 방식으로 작동한다. 따라서 스택 수행을 위한 별도 스레드는 없다. 그림 2와 그림 3은 제어 흐름을 매우 단순화한 것이고, 그림 4에서 좀 더 정확한 제어 흐름을 볼 수 있다.

networkstack3

그림 3 스택 내부 제어 흐름

그림 3의 (1)은 애플리케이션이 시스템 콜을 호출하여 TCP를 수행(사용)하는 경우다. 예를 들어, read 시스템 콜과 write 시스템 콜을 호출하고 TCP를 수행한다. 하지만 패킷 전송은 없다.

(2)는 (1)과 같은데, TCP 수행 결과 패킷 전송이 필요한 경우다. 패킷을 생성해서 드라이버로 패킷을 내려 보낸다. 드라이버의 앞 부분에는 큐(queue)가 있다. 패킷은 우선 큐에 들어가고, 큐 구현체가 패킷이 드라이버로 전달되는 시점을 결정한다. Linux의 qdisc(queue discipline)가 이것이다. Linux traffic control 기능은 qdisc를 조작하는 것이다. 기본으로 사용하는 qdisc는 단순한 FIFO(first-in-first-out) 큐이다. 다른 qdisc를 사용하면 인위적인 패킷 유실, 패킷 지연, 전송 속도 제한 등 여러 가지 효과를 달성할 수 있다. (1), (2)에서는 애플리케이션의 프로세스 스레드가 드라이버까지 실행한다.

(3) 흐름은 TCP가 사용하는 타이머가 만료된 경우다. 예를 들어, TIME_WAIT 타이머가 만료되면 TCP를 호출해서 연결을 삭제한다.

(4) 흐름은 (3)과 같이 TCP가 사용하는 타이머가 만료된 경우인데, TCP 수행 결과 패킷 전송이 필요한 경우다. 예를 들어 재전송 타이머(retransmit timer)가 만료되면, ACK를 받지 못한 패킷을 전송한다.

(3), (4) 흐름은 타이머 인터럽트를 처리한 softirq가 실행되는 과정이다.

NIC 드라이버가 인터럽트를 받으면 전송된 패킷을 반환한다(free). 대개 여기서 드라이버 실행이 끝난다. (5) 흐름은 transmit queue에 패킷이 적체된 경우다. 드라이버가 softirq를 요청하고, softirq 핸들러가 transmit queue를 실행해서 적체된 패킷을 드라이버로 보낸다.

NIC 드라이버가 인터럽트를 받고 새로 수신된 패킷을 발견하면 softirq를 요청한다. 수신 패킷을 처리하는 softirq가 드라이버를 호출해서 수신된 패킷을 상위 레이어로 전달한다. Linux는 이와 같이 수신 패킷을 처리하는 것을 NAPI(new API)라고 부른다. 드라이버가 상위 레이어로 직접 전달하지 않고, 상위 레이어가 직접 패킷을 가져가기 때문에 polling과 유사하다. 실제 코드는 NAPI poll 혹은 poll이라 부른다.

(6)은 TCP까지 수행한 경우, (7)은 추가 패킷 전송이 필요한 경우를 보여준다. (5), (6), (7) 모두 NIC 인터럽트를 처리한 softirq가 실행한다.

인터럽트와 수신 패킷 처리

인터럽트 처리는 복잡하지만 패킷 수신 처리에 따른 성능 문제를 이해하기 위해 필요하다. 그림 4에서 인터럽트 처리 과정을 볼 수 있다.

networkstack4

그림 4 인터럽트, softirq, 그리고 수신 패킷 처리

CPU 0이 애플리케이션 프로그램(user program)을 실행하고 있다고 가정하자. 이때 NIC가 패킷을 수신하고 CPU 0을 인터럽트한다. CPU는 커널 인터럽트(흔히 irq라고 부른다) 핸들러를 실행한다. 이 핸들러가 인터럽트 번호를 보고 드라이버 인터럽트 핸들러를 호출한다. 드라이버는 전송된 패킷은 반환하고, 수신된 패킷을 처리하기 위해 napi_schedule() 함수를 호출한다. 이 함수가 softirq(소프트웨어 인터럽트)를 요청한다.

드라이버 인터럽트 핸들러의 실행이 종료되면 커널 핸들러로 제어권이 돌아간다. 커널 핸들러가 softirq에 대한 인터럽트 핸들러를 실행시킨다.

Interrupt context가 실행되었으니 softirq context가 실행될 차례이다. Interrupt context와 softirq context가 실행되는 스레드는 같다. 하지만 스택이 서로 다르다. 그리고 interrupt context는 하드웨어 인터럽트를 차단하지만, softirq context는 하드웨어 인터럽트를 허용한다.

수신 패킷을 처리하는 softirq 핸들러는 net_rx_action() 함수이다. 이 함수는 드라이버의 poll() 함수를 호출한다. poll() 함수는 netif_receive_skb() 함수를 호출해서 수신 패킷을 한 개씩 상위 레이어로 보낸다. softirq 처리가 종료되면, 애플리케이션은 시스템 콜을 요청하기 위하여 중단했던 지점부터 다시 수행을 재개한다.

따라서 인터럽트를 받은 CPU가 수신 패킷을 처음부터 끝까지 처리한다. Linux, BSD, Microsoft Windows 모두 기본으로 이와 같이 작동한다.

패킷 수신을 많이 하는 서버 CPU 사용률을 보면 한 CPU만 열심히 softirq를 실행하는 현상을 종종 확인할 수 있다. 지금까지 설명한 수신 패킷 처리 방식 때문에 발생하는 현상이다. 이 문제를 풀기 위해 multi-queue NIC, RSS, RPS가 나왔다.

데이터 구조체

중요한 데이터 구조체 몇 개를 살펴보고 실제 코드를 따라가 보자.

sk_buff 구조체

첫째, 패킷을 의미하는 sk_buff 구조체 혹은 skb 구조체가 있다. 그림 5는 sk_buff 구조체의 일부를 보여준다. 기능이 발전되면서 이보다 더 복잡해졌지만 기본적으로 필요한 기능은 누구나 생각할 수 있는 것들이다.

networkstack5

그림 5 패킷 구조체 sk_buff

패킷 데이터, 메타 데이터 포함

패킷 데이터를 구조체가 직접 포함하고 있거나, 포인터를 사용해서 참조하고 있다. 그림 5에서는 패킷 일부는 (Ethernet부터 buffer까지) 데이터 포인터를 이용해 참조하고 있고, 추가 데이터(frags)는 실제 페이지를 참조하고 있다.

메타 데이터 영역에는 헤더, 페이로드 길이 등 필요한 정보를 저장한다. 예를 들어, 그림 5에서 볼 수 있는 mac_header에는 Ethernet 헤더, network_header에는 IP 헤더, transport_header에는 TCP 헤더 시작 위치를 가리키고 있는 포인터 데이터가 있다. 이런 방식은 TCP 프로토콜 매우 편리하게 처리할 수 있게 한다.

헤더 추가, 삭제

네트워크 스택의 각 레이어를 왔다갔다하며 헤더를 추가, 삭제한다. 효율적으로 처리하기 위해 포인터들을 사용한다. 예를 들어, Ethernet 헤더를 제거하려면, head 포인터를 증가하면 된다.

패킷 결합, 분리

Socket buffer에 패킷 페이로드 데이터를 추가, 삭제, 또는 패킷 체인 같은 작업을 효율적으로 수행하기 위해 linked list를 사용한다. next, prev 포인터가 이 용도로 사용된다.

빠른 할당(allocation)과 반환(free)

패킷을 생성할 때마다 구조체를 할당하기 때문에 빠른 allocator를 사용한다. 예를 들어, 10 Gigabit Ethernet 속도로 데이터를 전송하면 초당 1백만 패킷 이상을 생성, 제거해야 한다.

TCP control block

둘째, TCP 연결을 대표하는 구조체가 있다. 앞서 추상적으로 TCP control block이라 불렀는데, Linux는 tcp_sock을 사용한다. 그림 7에서 file, socket과 tcp_sock 등이 어떤 관계에 있는지 알 수 있다.

networkstack6

그림 6 TCP 연결 구조체

시스템 콜이 발생하면 시스템 콜을 호출한 애플리케이션이 사용하는 file descriptor에 있는 file을 찾는다. Unix 계열 운영체제에서는 socket, 저장을 위한 일반 files system 용 file, 디바이스 등 여러 가지를 모두 file로 추상화한다. 따라서 file 구조체는 최소한의 정보만 포함한다. Socket의 경우 별도 socket 구조체가 소켓 관련 정보를 저장하고, file은 socket을 포인터로 참조한다. Socket은 다시 tcp_sock을 참조한다. tcp_sock은 sock, inet_sock 등으로 세분화되어 있는데, TCP 외의 다양한 프로토콜을 지원하기 위해서다. 일종의 폴리모피즘과 비슷하다고 보면 되겠다.

tcp_sock에는 TCP 프로토콜이 사용하는 모든 상태 정보를 저장한다. 예를 들어, 시퀀스 번호, receive window, congestion control, 재전송 타이머 등 정보가 모두 모여 있다.

Send socket buffer와 receive socket buffer는 sk_buff 리스트이고, tcp_sock을 포함한다. IP routing 결과물인 dst_entry도 참조해서 매번 routing하지 않도록 한다. dst_entry를 사용해서 ARP 결과, 즉 목적지 MAC 주소도 쉽게 찾는다. dst_entry는 routing table의 일부이다. routing table의 구조는 상당히 복잡해서 이 글에서는 다루지 않겠다. dst_entry를 사용해서 패킷 송신에 사용해야 하는 NIC를 찾는다. NIC는 net_device 구조체로 표현한다.

따라서 file만 찾으면 TCP 연결을 처리하는데 필요한 모든 구조체(file부터 드라이버까지)를 포인터로 쉽게 찾을 수 있다. 이들 구조체의 크기가 TCP 연결 하나가 사용하는 메모리의 양이다. 메모리의 양은 수 KB 정도(패킷 데이터 제외)다. 메모리 사용량도 기능이 추가되면서 꾸준히 증가했다.

마지막으로 TCP 연결 lookup table이 있다. 해시 테이블(hash table)인데, 수신된 패킷이 속하는 TCP 연결을 찾는데 사용한다. 해시 값은 패킷의 <소스 IP, 타깃 IP, 소스 port, 타깃 port>를 입력 데이터로 하고, Jenkins hash 알고리즘을 사용해서 계산한다. 해시 함수는 해시 테이블 공격에 대한 방어를 고려해서 선택했다고 한다.

코드 따라가기: 데이터 전송

실제 Linux 커널 소스 코드를 따라가며 스택이 수행하는 주요 작업을 알아보자. 자주 사용하는 경로 두 개만 살펴보겠다.

우선 애플리케이션이 write 시스템 콜을 호출할 때 데이터가 전송되는 경로를 보자.

SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf, ...)  
{
struct file *file;  
[...]
file = fget_light(fd, &fput_needed);  
[...] ===>
ret = filp->f_op->aio_write(&kiocb, &iov, 1, kiocb.ki_pos);

struct file_operations {  
[...]
ssize_t (*aio_read) (struct kiocb *, const struct iovec *, ...)  
ssize_t (*aio_write) (struct kiocb *, const struct iovec *, ...)  
[...]
};

static const struct file_operations socket_file_ops = {  
[...]
.aio_read = sock_aio_read,
.aio_write = sock_aio_write,
[...]
};

write 시스템 콜을 호출하면 커널이 파일 레이어의 write() 함수를 수행한다. 우선 file descriptor fd의 실제 file 구조체를 가져온다. 그리고 aio_write를 호출한다. 이것은 함수 포인터이다. file 구조체를 보면 file_operations 구조체 포인터가 있는데, 이 구조체는 흔히 부르는 function table이고, aio_read, aio_write등 함수 포인터를 포함한다. 실제 소켓용 table은 socket_file_ops이다. 소켓이 사용하는 aio_write 함수는 sock_aio_write이다. Function table은 Java 인터페이스와 유사한 용도로 사용한다. 커널이 코드 추상화나 리팩토링(refactoring)을 할 때 흔히 사용한다.

static ssize_t sock_aio_write(struct kiocb *iocb, const struct iovec *iov, ..)  
{
[...]
struct socket *sock = file->private_data;  
[...] ===>
return sock->ops->sendmsg(iocb, sock, msg, size);

struct socket {  
[...]
struct file *file;  
struct sock *sk;  
const struct proto_ops *ops;  
};

const struct proto_ops inet_stream_ops = {  
.family = PF_INET,
[...]
.connect = inet_stream_connect,
.accept = inet_accept,
.listen = inet_listen, .sendmsg = tcp_sendmsg,
.recvmsg = inet_recvmsg,
[...]
};

struct proto_ops {  
[...]
int (*connect) (struct socket *sock, ...)  
int (*accept) (struct socket *sock, ...)  
int (*listen) (struct socket *sock, int len);  
int (*sendmsg) (struct kiocb *iocb, struct socket *sock, ...)  
int (*recvmsg) (struct kiocb *iocb, struct socket *sock, ...)  
[...]
};

sock_aio_write 함수는 file에서 socket 구조체를 가져오고 sendmsg를 호출한다. 이것도 함수 포인터이다. Socket 구조체는 proto_ops function table을 포함한다. IPv4 TCP가 구현한 proto_ops는 inet_stream_ops이고, sendmsg는 tcp_sendmsg가 구현하고 있다.

int tcp_sendmsg(struct kiocb *iocb, struct socket *sock,  
struct msghdr *msg, size_t size)  
{
struct sock *sk = sock->sk;  
struct iovec *iov;  
struct tcp_sock *tp = tcp_sk(sk);  
struct sk_buff *skb;  
[...]
mss_now = tcp_send_mss(sk, &size_goal, flags);

/* Ok commence sending. */
iovlen = msg->msg_iovlen;  
iov = msg->msg_iov;  
copied = 0;  
[...]
while (--iovlen >= 0) {  
int seglen = iov->iov_len;  
unsigned char __user *from = iov->iov_base;

iov++;  
while (seglen > 0) {  
int copy = 0;  
int max = size_goal;  
[...]
skb = sk_stream_alloc_skb(sk,  
select_size(sk, sg),  
sk->sk_allocation);  
if (!skb)  
goto wait_for_memory;  
/*
* Check whether we can use HW checksum.
*/
if (sk->sk_route_caps & NETIF_F_ALL_CSUM)  
skb->ip_summed = CHECKSUM_PARTIAL;  
[...]
skb_entail(sk, skb);  
[...]
/* Where to copy to? */
if (skb_tailroom(skb) > 0) {  
/* We have some space in skb head. Superb! */
if (copy > skb_tailroom(skb))  
copy = skb_tailroom(skb);  
if ((err = skb_add_data(skb, from, copy)) != 0)  
goto do_fault;  
[...]
if (copied)  
tcp_push(sk, flags, mss_now, tp->nonagle);  
[...]
}

tcp_sengmsg는 socket에서 tcp_sock, 즉 TCP control block을 가져오고, 애플리케이션이 전송 요청한 데이터를 send socket buffer로 복사한다. 데이터를 sk_buff로 복사할 때 sk_buff 하나가 몇 바이트를 포함해야 할까? 실제 패킷을 생성하는 코드를 돕기 위해 sk_buff 하나가 MSS(tcp_send_mss) 바이트가 포함되도록 복사한다. MSS는 'Maximum Segment Size'로 TCP 패킷 한 개가 포함하는 최대 페이로드 크기다. TSO, GSO를 사용하면 sk_buff 한 개가 MSS보다 더 많은 데이터를 저장하는데, 이 부분은 다음 기회에 설명하겠다.

sk_stream_alloc_skb 함수가 새로운 sk_buff를 생성하고, skb_entail이 send_socket_buffer 꼬리에 새로운 sk_buff를 추가한다. skb_add_data 함수가 실제 애플리케이션 데이터를 sk_buff의 데이터 버퍼로 복사한다. 이 과정(sk_buff 생성, send socket buffer에 추가)을 여러 번 반복해서 모든 데이터를 복사한다. 결국은 MSS 크기의 sk_buff들이 send socket buffer에 리스트로 묶여 있는 모양이 된다. 끝으로 tcp_push를 호출해서 지금 전송할 수 있는 데이터를 패킷으로 만들어서 전송한다.

static inline void tcp_push(struct sock *sk, int flags, int mss_now, ...)  
[...] ===>
static int tcp_write_xmit(struct sock *sk, unsigned int mss_now, ...)  
int nonagle,  
{
struct tcp_sock *tp = tcp_sk(sk);  
struct sk_buff *skb;  
[...]
while ((skb = tcp_send_head(sk))) {  
[...]
cwnd_quota = tcp_cwnd_test(tp, skb);  
if (!cwnd_quota)  
break;

if (unlikely(!tcp_snd_wnd_test(tp, skb, mss_now)))  
break;  
[...]
if (unlikely(tcp_transmit_skb(sk, skb, 1, gfp)))  
break;

/* Advance the send_head. This one is sent out.
* This call will increment packets_out.
*/
tcp_event_new_data_sent(sk, skb);  
[...]

tcp_push 함수는 TCP가 허용하는 만큼 send socket buffer의 sk_buff를 차례대로 전송한다. 우선 tcp_send_head 호출해서 socket buffer의 가장 앞에 있는 sk_buff를 가져오고, tcp_cwnd_test, tcp_snd_wnd_test로 congestion window과 수신 TCP의 receive window가 새로운 패킷 전송을 허용하는지 확인한다. 그리고 tcp_transmit_skb 함수를 호출해서 실제 패킷을 생성한다.

static int tcp_transmit_skb(struct sock *sk, struct sk_buff *skb,  
int clone_it, gfp_t gfp_mask)  
{
const struct inet_connection_sock *icsk = inet_csk(sk);  
struct inet_sock *inet;  
struct tcp_sock *tp;  
[...]


if (likely(clone_it)) {  
if (unlikely(skb_cloned(skb)))  
skb = pskb_copy(skb, gfp_mask);  
else  
skb = skb_clone(skb, gfp_mask);  
if (unlikely(!skb))  
return -ENOBUFS;  
}

[...]
skb_push(skb, tcp_header_size);  
skb_reset_transport_header(skb);  
skb_set_owner_w(skb, sk);

/* Build TCP header and checksum it. */
th = tcp_hdr(skb);  
th->source = inet->inet_sport;  
th->dest = inet->inet_dport;  
th->seq = htonl(tcb->seq);  
th->ack_seq = htonl(tp->rcv_nxt);  
[...]
icsk->icsk_af_ops->send_check(sk, skb);  
[...]
err = icsk->icsk_af_ops->queue_xmit(skb);  
if (likely(err <= 0))  
return err;

tcp_enter_cwr(sk, 1);

return net_xmit_eval(err);  
}

tcp_transmit_skb은 주어진 sk_buff의 복사본(pskb_copy)을 만든다. 이때 애플리케이션 데이터 전체를 복사하지 않고, 메타데이터만 복사한다. 그리고 skb_push를 호출해서 헤더 영역을 확보하고, 헤더 필드 값을 기록한다. send_check은 TCP checksum을 계산한다. Checksum offload를 사용하면 페이로드 데이터는 계산하지 않는다. 마지막으로, queue_xmit를 호출해서 IP 레이어로 패킷을 보낸다. IPv4 용 queue_xmit은 ip_queue_xmit 함수가 구현한다.

int ip_queue_xmit(struct sk_buff *skb)  
[...]
rt = (struct rtable *)__sk_dst_check(sk, 0);  
[...]
/* OK, we know where to send it, allocate and build IP header. */
skb_push(skb, sizeof(struct iphdr) + (opt ? opt->optlen : 0));  
skb_reset_network_header(skb);  
iph = ip_hdr(skb);  
*((__be16 *)iph) = htons((4 << 12) | (5 << 8) | (inet->tos & 0xff));
if (ip_dont_fragment(sk, &rt->dst) && !skb->local_df)  
iph->frag_off = htons(IP_DF);  
else  
iph->frag_off = 0;  
iph->ttl = ip_select_ttl(inet, &rt->dst);  
iph->protocol = sk->sk_protocol;  
iph->saddr = rt->rt_src;  
iph->daddr = rt->rt_dst;  
[...]
res = ip_local_out(skb);  
[...] ===>
int __ip_local_out(struct sk_buff *skb)  
[...]
ip_send_check(iph);  
return nf_hook(NFPROTO_IPV4, NF_INET_LOCAL_OUT, skb, NULL,  
skb_dst(skb)->dev, dst_output);  
[...] ===>
int ip_output(struct sk_buff *skb)  
{
struct net_device *dev = skb_dst(skb)->dev;  
[...]
skb->dev = dev;  
skb->protocol = htons(ETH_P_IP);

return NF_HOOK_COND(NFPROTO_IPV4, NF_INET_POST_ROUTING, skb, NULL, dev,  
ip_finish_output,  
[...] ===>
static int ip_finish_output(struct sk_buff *skb)  
[...]
if (skb->len > ip_skb_dst_mtu(skb) && !skb_is_gso(skb))  
return ip_fragment(skb, ip_finish_output2);  
else  
return ip_finish_output2(skb);  

ip_queue_xmit 함수는 IP 레이어에서 필요한 작업을 한다. __sk_dst_check은 캐시(cache)한 route가 유효한지 확인한다. 캐시한 route가 없거나 유효하지 않으면 IP routing을 한다. 그리고 skb_push를 호출해서 IP 헤더 영역을 확보하고, IP 헤더 필드 값을 기록한다. 이후 함수 호출을 따라가면, ip_send_check가 IP 헤더 checksum을 계산하고, netfilter 함수도 호출한다. ip_finish_output 함수가 IP fragmentation이 필요하면 fragment를 만든다. TCP 사용시 fragmentation은 발생하지 않는다. 결국은 ip_finish_output2가 호출되고, 이것이 Ethernet 헤더를 추가한다. 이로써 패킷이 완성된다.

int dev_queue_xmit(struct sk_buff *skb)  
[...] ===>
static inline int __dev_xmit_skb(struct sk_buff *skb, struct Qdisc *q, ...)  
[...]
if (...) {  
....
} else
if ((q->flags & TCQ_F_CAN_BYPASS) && !qdisc_qlen(q) &&  
qdisc_run_begin(q)) {  
[...]
if (sch_direct_xmit(skb, q, dev, txq, root_lock)) {  
[...] ===>
int sch_direct_xmit(struct sk_buff *skb, struct Qdisc *q, ...)  
[...]
HARD_TX_LOCK(dev, txq, smp_processor_id());  
if (!netif_tx_queue_frozen_or_stopped(txq))  
ret = dev_hard_start_xmit(skb, dev, txq);

HARD_TX_UNLOCK(dev, txq);  
[...]
}

int dev_hard_start_xmit(struct sk_buff *skb, struct net_device *dev, ...)  
[...]
if (!list_empty(&ptype_all))  
dev_queue_xmit_nit(skb, dev);  
[...]
rc = ops->ndo_start_xmit(skb, dev);  
[...]
}

완성된 패킷은 dev_queue_xmit 함수를 통해 전송된다. 먼저 qdisc를 거친다. 기본 qdisc를 사용하고 큐가 비어있으면 sch_direct_xmit 함수를 호출해서 큐를 거치지 않고 패킷을 바로 드라이버로 내려 보낸다. dev_hard_start_xmit 함수가 실제 드라이버를 호출하는데, 드라이버를 호출하기 전에 디바이스 TX 락을 잡는다. 여러 스레드가 동시에 디바이스 접근하는 것을 막기 위해서다. 커널이 락을 잡기 때문에, 드라이버 전송 코드는 별도 락이 필요 없다. 다음 기회에 설명할 병렬 처리와 밀접한 관계가 있다.

ndo_start_xmit 함수가 드라이버 코드를 호출한다. 바로 전에, ptype_all, dev_queue_xmit_nit가 보인다. ptype_all은 패킷 캡쳐 같은 모듈을 포함하는 리스트다. 캡쳐 프로그램이 작동 중이면, 여기서 해당 프로그램으로 패킷을 복사한다. 따라서 tcpdump가 보여 주는 패킷은 드라이버로 전달되는 패킷이다. Checksum offload, TSO 등을 사용하면 NIC가 패킷을 조작하기 때문에, tcpdump 패킷은 실제 네트워크 선으로 전송되는 패킷과 다르다. 패킷 전송이 완료되면 드라이버 인터럽트 핸들러가 sk_buff를 반환한다.

코드 따라가기: 데이터 수신

둘째로 흔히 수행하는 경로는 패킷을 수신해서 receive socket buffer에 데이터를 추가하는 작업이다. 드라이버 인터럽트 핸들러 수행 후 napi poll 핸들부터 따라가 보자.

static void net_rx_action(struct softirq_action *h)  
{
struct softnet_data *sd = &__get_cpu_var(softnet_data);  
unsigned long time_limit = jiffies + 2;  
int budget = netdev_budget;  
void *have;

local_irq_disable();

while (!list_empty(&sd->poll_list)) {  
struct napi_struct *n;  
[...]
n = list_first_entry(&sd->poll_list, struct napi_struct,  
poll_list);  
if (test_bit(NAPI_STATE_SCHED, &n->state)) {  
work = n->poll(n, weight);  
trace_napi_poll(n);  
}
[...]
}

int netif_receive_skb(struct sk_buff *skb)  
[...] ===>
static int __netif_receive_skb(struct sk_buff *skb)  
{
struct packet_type *ptype, *pt_prev;  
[...]
__be16 type;  
[...]
list_for_each_entry_rcu(ptype, &ptype_all, list) {  
if (!ptype->dev || ptype->dev == skb->dev) {  
if (pt_prev)  
ret = deliver_skb(skb, pt_prev, orig_dev);  
pt_prev = ptype;  
}
}
[...]
type = skb->protocol;  
list_for_each_entry_rcu(ptype,  
&ptype_base[ntohs(type) & PTYPE_HASH_MASK], list) {
if (ptype->type == type &&  
(ptype->dev == null_or_dev || ptype->dev == skb->dev ||
ptype->dev == orig_dev)) {  
if (pt_prev)  
ret = deliver_skb(skb, pt_prev, orig_dev);  
pt_prev = ptype;  
}
}

if (pt_prev) {  
ret = pt_prev->func(skb, skb->dev, pt_prev, orig_dev);

static struct packet_type ip_packet_type __read_mostly = {  
.type = cpu_to_be16(ETH_P_IP),
.func = ip_rcv,
[...]
};

앞서 설명했듯 net_rx_action 함수는 패킷을 수신하는 softirq 핸들러다. napi poll을 요청한 드라이버를 poll_list에서 가져와서 드라이버의 poll 핸들러를 호출한다. 드라이버는 수신한 패킷을 sk_buff로 포장하고, netif_receive_skb를 호출한다.

netif_receive_skb는 모든 패킷을 원하는 모듈이 있으면 그 모듈로 패킷을 전달한다. 패킷 전송 때와 같이 ptype_all 리스트에 등록된 모듈로 패킷을 전달한다. 패킷 캡처 작업은 여기서 수행된다.

그리고 패킷 종류에 따라 상위 레이어를 찾아 패킷을 전달한다. Ethernet 패킷은 헤더에 2 바이트 ethertype 필드를 포함한다. 이 값이 패킷 종류를 나타낸다. 이 값은 드라이버가 sk_buff에 기록한다(skb->protocol). 각 프로토콜은 자신만의 packet_type 구조체를 가지고, ptype_base hash table에 이 구조체의 포인터를 등록한다. IPv4는 ip_packet_type을 사용한다. Type 필드 값이 IPv4 ethertype (ETH_P_IP) 값이다. 따라서 IPv4 패킷은 ip_rcv 함수를 호출한다.

int ip_rcv(struct sk_buff *skb, struct net_device *dev, ...)  
{
struct iphdr *iph;  
u32 len;  
[...]
iph = ip_hdr(skb);  
[...]
if (iph->ihl < 5 || iph->version != 4)  
goto inhdr_error;

if (!pskb_may_pull(skb, iph->ihl*4))  
goto inhdr_error;

iph = ip_hdr(skb);

if (unlikely(ip_fast_csum((u8 *)iph, iph->ihl)))  
goto inhdr_error;

len = ntohs(iph->tot_len);  
if (skb->len < len) {  
IP_INC_STATS_BH(dev_net(dev), IPSTATS_MIB_INTRUNCATEDPKTS);  
goto drop;  
} else if (len < (iph->ihl*4))
goto inhdr_error;  
[...]
return NF_HOOK(NFPROTO_IPV4, NF_INET_PRE_ROUTING, skb, dev, NULL,  
ip_rcv_finish);  
[...] ===>
int ip_local_deliver(struct sk_buff *skb)  
[...]
if (ip_hdr(skb)->frag_off & htons(IP_MF | IP_OFFSET)) {  
if (ip_defrag(skb, IP_DEFRAG_LOCAL_DELIVER))  
return 0;  
}

return NF_HOOK(NFPROTO_IPV4, NF_INET_LOCAL_IN, skb, skb->dev, NULL,  
ip_local_deliver_finish);  
[...] ===>


static int ip_local_deliver_finish(struct sk_buff *skb)  
[...]
__skb_pull(skb, ip_hdrlen(skb));  
[...]
int protocol = ip_hdr(skb)->protocol;  
int hash, raw;  
const struct net_protocol *ipprot;  
[...]
hash = protocol & (MAX_INET_PROTOS - 1);  
ipprot = rcu_dereference(inet_protos[hash]);  
if (ipprot != NULL) {  
[...]
ret = ipprot->handler(skb);  
[...] ===>

static const struct net_protocol tcp_protocol = {  
.handler = tcp_v4_rcv,
[...]
};

ip_rcv 함수는 IP 레이어에서 필요한 일을 한다. 길이, 헤더 checksum 등 패킷 검사를 한다. netfilter 코드를 거치면 ip_local_deliver 함수를 수행한다. 여기서 필요하면 IP fragment들을 조립한다. 그리고 다시 netfilter 코드를 통해서 ip_local_deliver_finish를 호출한다. 이 함수는 __skb_pull을 사용해서 IP 헤더를 제거하고, IP 헤더의 protocol 값과 일치하는 상위 프로토콜을 찾는다. Ptype_base와 유사하게 각 트랜스포트 프로토콜은 inet_protos에 자신의 net_protocol 구조체를 등록한다. IPv4 TCP는 tcp_protocol을 사용하고, handler로 등록한 tcp_v4_rcv를 호출한다.

TCP 레이어로 들어오면 TCP 상태, 패킷 종류에 따라 패킷 처리 흐름이 다양하다. 여기서는 TCP 연결이 ESTABLISHED 상태에서 다음 예상하는 데이터 패킷을 받았을 때 처리 과정을 알아본다. 패킷 유실, 역전 현상이 없으면 데이터를 받는 서버가 자주 수행하는 경로다.

int tcp_v4_rcv(struct sk_buff *skb)  
{
const struct iphdr *iph;  
struct tcphdr *th;  
struct sock *sk;  
[...]
th = tcp_hdr(skb);

if (th->doff < sizeof(struct tcphdr) / 4)  
goto bad_packet;  
if (!pskb_may_pull(skb, th->doff * 4))  
goto discard_it;  
[...]
th = tcp_hdr(skb);  
iph = ip_hdr(skb);  
TCP_SKB_CB(skb)->seq = ntohl(th->seq);  
TCP_SKB_CB(skb)->end_seq = (TCP_SKB_CB(skb)->seq + th->syn + th->fin +  
skb->len - th->doff * 4);  
TCP_SKB_CB(skb)->ack_seq = ntohl(th->ack_seq);  
TCP_SKB_CB(skb)->when = 0;  
TCP_SKB_CB(skb)->flags = iph->tos;  
TCP_SKB_CB(skb)->sacked = 0;

sk = __inet_lookup_skb(&tcp_hashinfo, skb, th->source, th->dest);  
[...]
ret = tcp_v4_do_rcv(sk, skb);  

우선 tcp_v4_rcv 함수는 받은 패킷이 올바른지 검사한다. 예를 들어, 헤더 크기가 데이터 오프셋보다 크면(th->doff < sizeof(struct tcphdr) / 4) 헤더 오류이다. 그리고 __inet_lookup_skb를 호출해서 TCP 연결 해시 테이블에서 패킷이 속하는 연결을 찾는다. 찾은 sock 구조체로부터 tcp_sock, socket 등 모든 필요한 구조체를 가져올 수 있다.

int tcp_v4_do_rcv(struct sock *sk, struct sk_buff *skb)  
[...]
if (sk->sk_state == TCP_ESTABLISHED) { /* Fast path */  
sock_rps_save_rxhash(sk, skb->rxhash);  
if (tcp_rcv_established(sk, skb, tcp_hdr(skb), skb->len)) {  
[...] ===>
int tcp_rcv_established(struct sock *sk, struct sk_buff *skb,  
[...]
/*
* Header prediction.
*/
if ((tcp_flag_word(th) & TCP_HP_BITS) == tp->pred_flags &&  
TCP_SKB_CB(skb)->seq == tp->rcv_nxt &&  
!after(TCP_SKB_CB(skb)->ack_seq, tp->snd_nxt))) {
[...]
if ((int)skb->truesize > sk->sk_forward_alloc)  
goto step5;

NET_INC_STATS_BH(sock_net(sk), LINUX_MIB_TCPHPHITS);

/* Bulk data transfer: receiver */
__skb_pull(skb, tcp_header_len);  
__skb_queue_tail(&sk->sk_receive_queue, skb);  
skb_set_owner_r(skb, sk);  
tp->rcv_nxt = TCP_SKB_CB(skb)->end_seq;  
[...]
if (!copied_early || tp->rcv_nxt != tp->rcv_wup)  
__tcp_ack_snd_check(sk, 0);  
[...]
step5:  
if (th->ack && tcp_ack(sk, skb, FLAG_SLOWPATH) < 0)  
goto discard;

tcp_rcv_rtt_measure_ts(sk, skb);

/* Process urgent data. */
tcp_urg(sk, skb, th);

/* step 7: process the segment text */
tcp_data_queue(sk, skb);

tcp_data_snd_check(sk);  
tcp_ack_snd_check(sk);  
return 0;  
[...]
}

tcp_v4_do_rcv 함수부터 실제 프로토콜을 수행한다. ESTABLISHED 상태는 tcp_rcv_esablished를 호출한다. ESTABLISHED 상태가 가장 흔하기 때문에 이 상태 처리를 별도로 떼어 내서 최적화한다. tcp_rcv_established는 header prediction 코드를 먼저 수행한다. Header prediction도 흔한 경우를 감지해서 빨리 처리한다. 여기서 흔한 경우는 보낼 데이터는 없고, 받은 데이터 패킷이 다음에 받아야 하는 패킷인 경우, 즉 시퀀스 번호가 수신 TCP가 기대하는 시퀀스 번호인 경우다. 데이터를 소켓 버퍼에 추가하고 ACK를 전송하면 끝이다.

좀 더 따라가 보면 truesize와 sk_forward_alloc을 비교하는 문장이 보인다. Receive socket buffer에 새로운 패킷 데이터를 추가할 여유 공간이 있는지 확인한다. 공간이 있으면 header prediction은 "hit" (prediction 성공)이다. __skb_pull를 호출해서 TCP 헤더를 제거하고, __skb_queue_tail을 호출해서 패킷을 receive socket buffer에 추가한다. 마지막으로, __tcp_ack_snd_check를 호출해서 ACK 전송이 필요하면 전송한다. 이것이 패킷 처리의 끝이다.

만약 여유 공간이 부족하면 느린 경로를 수행한다. tcp_data_queue 함수는 버퍼 공간을 새로 할당하고 데이터 패킷을 소켓 버퍼에 추가한다. 이때 가능하면 receive socket buffer 크기를 자동으로 증가한다. 빠른 경로와 다르게, tcp_data_snd_check를 호출해서 새로운 데이터 패킷을 전송할 수 있으면 전송하고, 끝으로 tcp_ack_snd_check 호출해서 ACK 전송이 필요하면 ACK 패킷을 생성해서 전송한다.

지금 따라가 본 두 경로가 수행하는 코드의 양은 많지 않다. 자주 발생하는 경우(common case)를 최적화한 덕분이다. 바꿔 말하면 예상하지 않은 경우(uncommon case)의 처리 과정은 눈에 띄게 느리다는 뜻도 된다. 패킷 역전(out-of-order delivery) 현상이 대표적인 예다.

드라이버와 NIC의 통신

드라이버와 NIC 사이의 통신은 스택 가장 밑단이고, 대개 신경 쓰지 않는다. 하지만 성능 문제를 해결하기 위해 NIC가 수행하는 일이 많아졌다. 기본적인 작동 방식을 이해하면 추가 기술을 이해하는 데 도움이 된다.

드라이버와 NIC는 비동기 방식으로 통신한다. 먼저 드라이버가 패킷 전송을 요청하고(호출), CPU는 응답을 기다리지 않고 다른 작업을 수행한다. 이후 NIC가 패킷을 전송하고 CPU에 이 사실을 알리면 드라이버가 전송된 패킷을 반환한다(결과 리턴). 수신도 이와 같이 비동기 방식으로 이루어진다. 먼저 드라이버가 수신 요청을 하고 CPU는 다른 작업을 수행한다(호출). 이후 NIC가 패킷을 받으면 CPU에 이 사실을 알리고, 드라이버가 받은 패킷을 처리한다(결과 리턴).

따라서 요청, 응답을 저장하는 장소가 필요하다. 대개 NIC는 링(ring) 구조체를 사용한다. 링은 일반 큐 구조체와 유사하다. 고정된 수의 엔트리를 가지고, 한 엔트리가 한 요청 혹은 응답 데이테를 저장한다. 이들 엔트리들을 차례대로 돌아가며 사용한다. 돌아가며 고정된 엔트리들을 재사용하기 때문에 흔히 링이란 이름을 사용한다.

다음 그림의 패킷 전송 과정을 따라가며 링을 어떻게 사용하는지 알아보자.

networkstack7

그림 7 드라이버-NIC 통신: 패킷 전송

드라이버가 상위 레이어로부터 패킷을 받고, NIC가 이해하는 전송 요청(send descriptor)을 생성한다. send descriptor에는 기본적으로 패킷 크기, 메모리 주소를 포함하도록 한다. NIC는 메모리에 접근할 때 필요한 물리적 주소가 필요하다, 따라서 드라이버가 패킷의 가상 주소를 물리적 주소로 변경한다. 그리고 send descriptor를 TX ring에 추가한다(1). TX ring이 전송 요청 링이다.

그리고 NIC에 새로운 요청이 있다고 알린다(2). 특정 NIC 메모리 주소에 드라이버가 직접 데이터를 쓴다. 이와 같이 CPU가 디바이스에 직접 데이터를 전송하는 방식을 PIO(Programmed I/O)라고 한다.

연락을 받은 NIC는 TX ring의 send descriptor를 호스트 메모리에서 가져온다(3). CPU의 개입 없이 디바이스가 직접 메모리에 접근하기 때문에, 이와 같은 접근을 DMA(Direct Memory Access)라고 부른다.

Send descriptor를 가져와서 패킷 주소와 크기를 판단하고, 실제 패킷을 호스트 메모리에서 가져온다(4). Checksum offload 방식을 사용하면 메모리에서 패킷 데이터를 가져올 때 checksum을 NIC가 계산하도록 한다. 따라서 오버헤드는 거의 발생하지 않는다.

NIC가 패킷을 전송하고(5), 패킷을 몇 개 전송했는지 호스트의 메모리에 기록한다(6). 그리고 인터럽트를 보낸다(7). 드라이버는 전송된 패킷 수를 읽어 와서 현재까지 전송된 패킷을 반환한다.

다음 그림에서는 패킷 수신 과정을 볼 수 있다.

networkstack8

그림 8 드라이버-NIC 통신: 패킷 수신

우선 드라이버가 패킷 수신용 호스트 메모리 버퍼를 할당하고, receive descriptor를 생성한다. receive descriptor는 기본으로 버퍼의 크기, 주소를 포함한다. send descriptor와 같이 DMA가 사용하는 물리적 주소를 descriptor에 저장한다. 그리고 RX ring에 descriptor를 추가한다(1). 결국 이것이 수신 요청이고, RX ring은 수신 요청 링이다.

드라이버가 PIO를 통해서 NIC에 새로운 descriptor가 있다고 알린다(2). NIC는 RX ring의 새로운 descriptor를 가져온다. 그리고 descriptor에 포함된 버퍼의 크기, 위치를 NIC 메모리에 보관한다(3).

이후 패킷이 도착하면(4), NIC는 호스트 메모리 버퍼로 패킷을 전송한다(5). Checksum offload 기능이 있다면 NIC가 이때 checksum을 계산한다. 도착한 패킷의 실제 크기와 checksum 결과, 그 외 다른 정보는 별도의 링(receive return ring)에 기록한다(6). Receive return ring은 수신 요청 처리 결과, 즉 응답을 저장하는 링이다. 그리고 NIC가 인터럽트를 보낸다(7). 드라이버는 receive return ring에서 패킷 정보를 가져와서 받은 패킷을 처리한다. 필요에 따라 새로운 메모리 버퍼를 할당하고 (1)~(2) 단계를 반복한다.

스택 튜닝이라고 하면 흔히 ring, interrupt 설정을 조절해야 한다고 이야기한다. TX ring이 크면 한 번에 많은 수의 전송 요청을 할 수 있다. RX ring이 크면 한 번에 많은 수의 수신을 할 수 있다. 패킷 송신, 수신 burst가 많은 워크로드에는 큰 링이 도움이 된다. 그리고 CPU가 인터럽트를 처리하는 오버헤드가 크기 때문에, 대개 NIC은 인터럽트 회수를 줄이기 위해 타이머를 사용한다. 패킷을 전송하고 수신할 때 매번 인터럽트를 보내지 않고 주기적으로 모아서 보낸다(interrupt coalescing).

스택 내부 버퍼와 제어 흐름(flow control)

스택 내부의 여러 단에서 flow control을 수행한다.

그림 9는 데이터를 전송할 때 사용하는 버퍼를 보여 준다. 우선, 애플리케이션이 데이터를 생성하고 send socket buffer에 추가한다. 공간이 없으면 시스템 콜이 실패하거나, 애플리케이션 스레드에 블로킹이 발생한다. 따라서 커널로 유입되는 애플리케이션 데이터의 속도는 socket buffer 크기 제한을 통해 제어하도록 한다.

networkstack9

그림 9 패킷 전송에 관계된 버퍼들

TCP가 패킷을 생성해서 드라이버로 보낼 때는 transmit queue(qdisc)를 통하도록 하고 있다. 기본적인 FIFO 큐 형태이고, 큐의 최대 길이는 ifconfig 명령어를 실행할 때 확인할 수 있는 txqueuelen의 값이다. 보통 수 천 패킷 정도이다.

드라이버와 NIC 사이에는 TX ring이 있다. 앞서 설명했듯, 전송 요청 큐로 보면 된다. 큐 공간이 없으면 전송 요청을 못하고 패킷은 transmit queue에 적체된다. 너무 많이 적체되면 패킷 드롭을 한다.

NIC는 내부 버퍼에 전송할 패킷을 저장한다. 이 버퍼에서 패킷이 빠져나가는 속도는 우선 물리적 속도에 영향을 받는다(예: 1 Gb/s NIC가 10 Gb/s 성능을 낼 수는 없다). 그리고 Ethernet flow control을 사용하면 수신 NIC 버퍼에 공간이 없을 때는 전송이 멈춘다.

커널이 전송하는 패킷 속도가 NIC가 전송하는 속도보다 빠르면, 우선 NIC 내부 버퍼에 패킷이 적체된다. 버퍼에 공간이 없으면 TX ring의 전송 요청 처리를 멈춘다. TX ring에 점점 많은 요청이 적체되고, 결국은 큐 공간이 없어진다. 드라이버는 전송 요청을 못하고 패킷은 transmit queue에 적체된다. 이와 같이 여러 버퍼를 통해 backpressure가 밑에서 위로 올라간다.

그림 10은 반대로 수신한 패킷이 거쳐가는 버퍼를 보여 준다. 패킷은 NIC 내부 수신 버퍼에 저장된다. Flow control 관점에서 보면 드라이버와 NIC 사이의 RX ring를 패킷 버퍼로 생각하면 된다. RX ring에 들어간 패킷은 드라이버가 꺼내서 상위 레이어로 보낸다. 서버 장비가 사용하는 NIC 드라이버는 기본으로 NAPI를 사용하기 때문에 드라이버와 상위 레이어 사이에 버퍼는 없다. 상위 레이어가 RX ring에서 직접 패킷을 가져간다고 생각하면 된다. 그리고 패킷의 페이로드 데이터는 receive socket buffer에 들어간다. 이후 애플리케이션이 socket buffer에서 데이터를 가져간다.

networkstack10

그림 10 패킷 수신에 관계된 버퍼들

NAPI를 지원하지 않는 드라이버는 backlog queue에 패킷을 넣고, 후에 NAPI 핸들러가 패킷을 꺼내간다. 따라서, backlog queue는 상위 레이어와 드라이버 사이 버퍼로 보면 된다.

커널의 패킷 처리 속도가 NIC로 유입되는 패킷 속도보다 느리면 RX ring 공간이 없어진다. 그리고 NIC 내부 버퍼 공간도 없어진다. Ethernet flow control을 사용하면 NIC가 송신 NIC에 송신 정지 요청을 보내거나 패킷 드롭을 한다.

TCP는 end-to-end flow control을 지원하기 때문에, receive socket buffer 공간 부족으로 인한 패킷 드롭은 없다. 하지만 UDP는 flow control을 지원하지 않기 때문에, 애플리케이션 속도가 느리면 socket buffer 공간 부족으로 패킷 드롭이 발생한다.

그림 9와 그림 10에서 드라이버가 사용하는 TX ring의 크기와 RX ring의 크기가 ethtool이 보여 주는 링의 크기다. 대개 throughput을 중요시하는 워크로드에는 링의 크기, socket buffer 크기를 늘리면 도움이 된다. 많은 패킷을 빠른 속도로 전송, 수신할 때 버퍼 공간 부족으로 인한 실패 확률이 줄어들기 때문이다.

끝으로

네트워크 프로그램, 성능 실험, 트러블슈팅에 도움이 될만한 것들만 설명하려 했는데 정리하고 보니 내용이 적지 않게 되었다. 네트워크 애플리케이션을 개발하거나 성능 모니터링을 할 때 도움이 되었으면 한다. TCP/IP 프로토콜 자체는 상당히 복잡하고 예외 케이스도 많다. 하지만 성능 이해와 현상 분석을 하기 위해 운영체제의 TCP/IP 관련 코드 한 줄 한 줄을 모두 이해하고 있을 필요는 없다. 큰 흐름만 알고 있어도 많은 도움이 된다.

장비 성능과 운영체제의 네트워크 스택 구현이 꾸준히 발전해서, 최신 서버는 10-20 Gb/s 정도의 TCP throughput은 무리 없이 달성하고 있다. TSO, LRO, RSS, GSO, GRO, UFO, XPS, IOAT, DDIO, TOE 등 alphabet soup 같이 성능 관련한 기술 종류들이 많아서 오히려 혼란스러울 정도이다.

다음 글에서는 성능관점에서 스택을 살펴보고, 이 기술들이 해결하려는 문제와 효과를 설명하겠다.

반응형