Timetombs

泛义的工具是文明的基础,而确指的工具却是愚人的器物

66h / 116a
,更新于 2024-04-06T22:06:23Z+08:00 by   35f4f88

[计算机网络编程] Socket API

版权声明 - CC BY-NC-SA 4.0

Socket1是一套抽象的用于网络通信的API,它使得应用层可以不必关心底层繁琐的传输通信细节。

开始之前最好具备一些计算机网络2的基础,TCP3以及网络字节序4的相关知识储备。

1 基础简介

方便起见,这里假设底层是IPv4和TCP。

1.1 地址结构

既然是建立通信,那么就需要知道对方的地址。socket使用struct sockaddr_in来存储连接一方的ipport

typedef __uint32_t in_addr_t;     /* base type for internet address */
typedef __uint8_t  sa_family_t; 

struct in_addr {
    in_addr_t s_addr;              /* 32 bit Internet address */
};

/* IPv4专属的 */
struct sockaddr_in {
    __uint8_t       sin_len;       /* struct length */
    sa_family_t     sin_family;    /* address family */
    in_port_t       sin_port;      /* 16bit port number */
    struct in_addr  sin_addr;      /* 32bit IPv4 */
    char            sin_zero[8];   /* unused */
};

/* 通用的 */
struct sockaddr {
    __uint8_t       sa_len;         /* struct length */
    sa_family_t     sa_family;      /* address family */
    char            sa_data[14];    /* sin_port sin_addr sin_zero[8] */
};

2 函数

2.1 socket函数

socket5用于创建一个用于通信的endpoint。使用方 clientserver

#include <sys/socket.h>

int socket(int domain, int type, int protocol);

参数:

  1. int domain:通信领域。比如AF_UNIXAF_INETAF_INET6分别代表unix本地通信、IPv4和IPv6。
  2. int type:通信类型。比如SOCK_STREAMSOCK_DGRAM分别代表向上层提供stream和datagram形式的数据。
  3. int protocol:应用层协议类型。比如IPPROTO_TCPIPPROTO_UDP

返回值:

  1. 成功:文件描述fd。
  2. 失败:-1errno6代表具体的错误类型。

示例:

int listen_fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (listen_fd == -1){
  // check errno
}

2.2 bind函数

bind7一个地址到sockfd上。使用方仅server

#include <sys/socket.h>

int bind (int sockfd, const struct sockaddr *myaddr, socklen_t addrlen);

参数:

  1. int sockfd:已创建的sockfd。
  2. const struct sockaddr *myaddr:绑定的地址。
  3. socklen_t addrlen:地址struct的长度,用于指导bind函数内部应该在myaddr指针上读取多少数据作为地址。

返回值:

  1. 成功:0
  2. 失败:-1errno6代表具体的错误类型。

示例:

struct sockaddr_in server_address;

bzero(&server_address, sizeof(server_address));
server_address.sin_family = AF_INET;
server_address.sin_addr.s_addr = htons(INADDR_ANY);
server_address.sin_port = htons(LISTEN_PORT);

int bind_result = bind(listen_fd, (struct sockaddr *)&server_address, sizeof(server_address);
if (bind_result == -1){
  // check errno
}

2.3 listen函数

listen8开始监听连接。使用方仅server

#include <sys/socket.h>

int listen (int sockfd, int backlog);

参数:

  1. int sockfd:已bind的sockfd。
  2. int backlog:最大的队列长度。如果连接数超过了最大队列长度,那么新的连接就会收到一个ECONNREFUSED错误。
    1. 在linux 2.2后,backlog指的是accept queue size.
    2. syn queue size的设置位于/proc/sys/net/ipv4/tcp_max_syn_backlog

返回值:

  1. 成功:0
  2. 失败:-1errno6代表具体的错误类型。

示例:

int listen_result = listen(listen_fd, 10);
if (listen_result == -1){
  // check errno
}

2.4 connect函数

connect9一个处于listen状态的server。使用方仅client。一直阻塞到建立连接后才返回。

#include <sys/socket.h>

int connect(int sockfd, const struct sockaddr *servaddr, socklen_t addrlen);

参数:

  1. int sockfdclient创建的sockfd。
  2. const struct sockaddr *servaddrserver的地址。
  3. socklen_t addrlen:地址struct的长度,用于指导connect函数内部应该在servaddr指针上读取多少数据作为地址。

返回值:

  1. 成功:0
  2. 失败:-1errno6代表具体的错误类型。

示例:

struct sockaddr_in server_address;

bzero(&server_address, sizeof server_address);
server_address.sin_family = AF_INET;
server_address.sin_port = htons(SERVER_PORT);
inet_pton(AF_INET, "127.0.0.1", &(server_address.sin_addr));

int connect_result = connect(client_sockfd, (struct sockaddr *)&server_address, sizeof(server_address));
if (connect_result == -1){
  // check errno
}

2.5 accept函数

accept10获取一个已经建立的连接。使用方仅server。一直阻塞到获取到一个连接后才返回。

#include <sys/socket.h>

int accept (int sockfd, struct sockaddr *cliaddr, socklen_t *addrlen);

参数:

  1. int sockfd:上面listen后的sockfd。
  2. struct sockaddr *cliaddr:建立连接的client的地址。
  3. socklen_t *addrlen:地址struct的长度,用于指导accept函数内部应该在cliaddr指针上写入多少数据。

返回值:

  1. 成功:当着这个连接的文件描述符fd。
  2. 失败:-1errno6代表具体的错误类型。

示例:

struct sockaddr_in client_address;
socklen_t client_length = sizeof(client_address);

int connect_fd = accept(listen_fd, (struct sockaddr *)&client_address, &client_length);
if (connect_fd == -1){
  // check errno
}

2.6 send函数

send11发送数据到内核的发送缓冲区。使用方 clientserver

#include <sys/socket.h>

ssize_t send(int sockfd, const void *buf, size_t len, int flags);

参数:

  1. int sockfd:上面connect后的或者accept后的sockfd。
  2. const void *buf:准备发送的数据的指针。
  3. size_t len:准备发送的数据的最大长度。
  4. int flags:。

返回值:

  1. 成功:发送成功的数据长度,可能会小于size_t len参数。
  2. 失败:-1errno6代表具体的错误类型。

示例:

//

2.7 recv函数

recv12从内核的接收缓冲区中读取数据。使用方 clientserver

#include <sys/socket.h>

ssize_t recv(int sockfd, void *buf, size_t len, int flags);

参数:

  1. int sockfd:上面connect后的或者accept后的sockfd。
  2. const void *buf:准备接收数据的buf指针。
  3. size_t len:准备接收的数据的最大长度。
  4. int flags:。

返回值:

  1. 成功:接收成功的数据长度,可能会小于size_t len参数。当返回0时表示对方已经断开了连接。
  2. 失败:-1errno6代表具体的错误类型。

示例:

//

2.8 close函数

close13关闭连接。使用方 clientserver

#include <unistd.h>

int close (int fd);

参数:

  1. int fd:待关闭的fd。

返回值:

  1. 成功:0
  2. 失败:-1errno6代表具体的错误类型。

示例:

int close_result = close(connect_fd);
if (close_result == -1){
  // check errno
}

3 TCP State

下图展示了每个函数对应的调用时机以及TCP3的状态流转。

TCP连接中的socket函数和状态

4 Example

一个C语言编写的基于Socket API的Echo程序,由其中的两个文件构成:

  1. socket-server.c
  2. socket-client.c

源码中对socket原生函数添加了包装,命名方式为xxx_e,函数签名完全保持一致,不同之处在于包装函数内部增加了logerror记录处理。创建和初始化socket的代码比较简单,做成来通用方法放在了https://github.com/linianhui/networking-programming/blob/io-multiplexing/src/cnp.c中,这里就不做解释了。

4.1 client

#include "cnp.h"

void cli(FILE *input, int connect_fd)
{
    char recv_buf[BUFFER_SIZE];
    char send_buf[BUFFER_SIZE];

    while (1)
    {
        // 打印用户输入提示符
        log_stdin_prompt();

        // 读取用户输入,阻塞
        bzero(send_buf, sizeof(send_buf));
        fgets(send_buf, BUFFER_SIZE, input);

        send_e(connect_fd, send_buf, strlen(send_buf) + 1, 0);

        // 接收server响应,阻塞
        bzero(recv_buf, sizeof(recv_buf));
        recv_e(connect_fd, recv_buf, BUFFER_SIZE, 0);
    }
}

int main(int argc, char *argv[])
{
    int connect_fd = socket_create_connect(argc, argv);
    cli(stdin, connect_fd);
    return 0;
}

上述client的逻辑非常简单,主要可以分成4部分:

  1. 启动后使用TCP连接指定的服务器(默认127.0.0.1:12345)。
  2. 连接成功开启一个while循环,循环内部阻塞在fgets14调用处(fgets可以从stdin15获取用户输入的一行文字)。
  3. 当获取到用户输入后,fgets从阻塞中返回,把读取到的数据通过send发送到connect_fd背后的服务器。
  4. 随后阻塞在recv调用处,等待读取服务器的响应。然后回到第2步继续循环。

4.2 server

#include "cnp.h"

void echo(int connect_fd)
{
    char buf[BUFFER_SIZE];
    int recv_size;

    while (1)
    {
        bzero(buf, sizeof(buf));
        // 读取接收的数据,阻塞
        recv_size = socket_revc_and_send(connect_fd, buf);
        if (recv_size == 0)
        {
            break;
        }
    }
}

void fork_handler(int listen_fd){
    int connect_fd;

    while (1)
    {
        // 获取已建立的连接,阻塞。
        connect_fd = accept_e(listen_fd, NULL, NULL);
        if (fork() == 0)
        {
            echo(connect_fd);
            exit(0);
        }
        close_e(connect_fd);
    }
}

int main(int argc, char *argv[])
{
    int listen_fd = socket_create_bind_listen(argc, argv);
    fork_handler(listen_fd);
    return 0;
}

server看起来更简单了一点(只处理一个fd)。主要也是4部分构成:

  1. 启动后监听IPv4的TCP端口(默认0.0.0.0:12345)。
  2. 使用while循环(循环的目的在于服务器要处理客户端的多个连接)调用accept。当没有客户端请求建立连接时,会一直阻塞在此处。
  3. 客户端成功建立连接,accept函数从阻塞中返回一个connect_fd,代表这一个TCP连接。随后使用fork16开启一个新的线程处理这个连接。然后主线程进入下一次循环,继续阻塞在accept处。
  4. 新线程执行echo函数,用循环执行recvsendsend的内容是把recv到的数据转成大写再发回去。如果recv到了0个字节的数据,则表明收到了对方的FIN,此时退出循环,echo方法结束。新线程也结束了。

4.3 遗留问题

先说cleint:

  1. fgets阻塞整个主线程,导致后面的recv即使有了数据,也无法读取。
  2. send阻塞,导致recv跟着被阻塞。不过这个问题不大。
  3. recv阻塞,导致用户一直不能输入,必须recv完成后才行。

总结来说就是stdinconnect_fd这两个不同的IO互相阻塞对方,同时只能处理一个,效率太差。

再说server:

  1. 由于accept函数是阻塞的,并且一次只能获取一个连接。
  2. 同时recv也是阻塞的,为了支持处理多个连接,不得不使用fork为每一个连接开启新的线程。线程成本高昂,无法支撑太多的线程。

总结来说是多线程虽然可以解决问题,但是性价比不高。

5 参考

下一篇 : [计算机网络编程] IO Multiplexing API