socket 编程

最近学习了一下socket编程,之前只会调用 api 按照书上/网上的方法按部就班地实现,这次集中梳理一下socket编程中一些关键的概念,以及如何去理解这些概念。

1 socket 类型

socket 方法是一种 IPC(inter process communication)方法

socket 除了常见的同一主机(AF_UNIX)和网络上的主机(AF_INET/AF_INET6)交换信息外,还包含了许多其他类型:

/* Address families.  */
#define AF_UNSPEC	PF_UNSPEC
#define AF_LOCAL	PF_LOCAL
#define AF_UNIX		PF_UNIX
#define AF_FILE		PF_FILE
#define AF_INET		PF_INET
#define AF_AX25		PF_AX25
#define AF_IPX		PF_IPX
#define AF_APPLETALK	PF_APPLETALK
#define AF_NETROM	PF_NETROM
#define AF_BRIDGE	PF_BRIDGE
#define AF_ATMPVC	PF_ATMPVC
#define AF_X25		PF_X25
#define AF_INET6	PF_INET6
#define AF_ROSE		PF_ROSE
#define AF_DECnet	PF_DECnet
#define AF_NETBEUI	PF_NETBEUI
#define AF_SECURITY	PF_SECURITY
#define AF_KEY		PF_KEY
#define AF_NETLINK	PF_NETLINK
#define AF_ROUTE	PF_ROUTE
#define AF_PACKET	PF_PACKET
#define AF_ASH		PF_ASH
#define AF_ECONET	PF_ECONET
#define AF_ATMSVC	PF_ATMSVC
#define AF_RDS		PF_RDS
#define AF_SNA		PF_SNA
#define AF_IRDA		PF_IRDA
#define AF_PPPOX	PF_PPPOX
#define AF_WANPIPE	PF_WANPIPE
#define AF_LLC		PF_LLC
#define AF_IB		PF_IB
#define AF_MPLS		PF_MPLS
#define AF_CAN		PF_CAN
#define AF_TIPC		PF_TIPC
#define AF_BLUETOOTH	PF_BLUETOOTH
#define AF_IUCV		PF_IUCV
#define AF_RXRPC	PF_RXRPC
#define AF_ISDN		PF_ISDN
#define AF_PHONET	PF_PHONET
#define AF_IEEE802154	PF_IEEE802154
#define AF_CAIF		PF_CAIF
#define AF_ALG		PF_ALG
#define AF_NFC		PF_NFC
#define AF_VSOCK	PF_VSOCK
#define AF_KCM		PF_KCM
#define AF_QIPCRTR	PF_QIPCRTR
#define AF_SMC		PF_SMC
#define AF_MAX		PF_MAX

事实上除了少数的几个协议族使用频率较高之外,其他的协议族比较少见。其中QEMU虚拟CAN设备时就使用了AF_CAN协议族。

2 socketfd、socketaddr 和 addrinfo

socketfd可理解为socket在内核中的具象化体现,它本质上是一个文件描述符,存在于内核空间中,用户无法直接看到。而对于一个普通的文件描述符(fd)来说,用户可以直观地看到文件(或者说路径 path),对应文件的路径,socket对用户暴露出来的是socketaddr

对于不同的socket来说,使用的socketaddr是不同的AF_UNIX使用一个路径名,AF_INTE/AF_INTE6使用 IP 地址和端口。

AF_INTE/AF_INTE6使用的 IP 地址和端口在内核中可用addrinfo来表示。使用getaddrinfo方法可以获取一个socketaddr,使用getnameinfo方法可以反过来获取对应的 IP 地址和端口。

转换关系如图:

3 socket 所在网络层次

4 并发型服务器设计方案

并发型服务器设计方案有很多

  1. 采用 fork 进程的方式,每当有一个客户端连接到服务端,则开启一个子进程专门处理该客户端请求;
  2. 采用线程池或进程池的方式,方案 1 的进阶版,该方案的要点在于,提前建立多个子进程(子线程),客户端连接时,子进程(子线程)处理客户端请求,客户端请求结束后,子进程(子线程)不终止,等待下一个待处理的客户端请求;
  3. 采用 I/O 多路复用的方式, 在单一进程中通过同时监控多个文件描述符(socketfd 也是文件描述符)上的 I/O 事件的模型(I/O 多路复用、型号驱动 I/O 或者 epoll)。

5 SOCKET TCP 粘包问题

在 tcp 开发中最容易遇到的问题就是粘包问题。

在我写的客户端中,进行了频繁的 send 操作,服务端中循环 recv,但是在服务端中 recv 到的数据却总是缺几条,并且缺失的数量和位置都不固定。代码如下:

1
2
3
4
5
6
7
8
9
/* server */
#define BUF_SIZE 4096
int main(int argc, char** argv){
    /* 创建监听 socket,accept。省略。。。*/
    while((numRead = recv(fd, buf, BUF_SIZE, 0)) >= 0)
    {
        printf("buf: %s\n", buf);
    }
}
1
2
3
4
5
6
7
8
9
10
11
/* client */
#define BUF_SIZE 4096
int main(int argc, char** argv){
    /* connect fd。省略。。。*/
    numWrite = send(fd, "hello1", BUF_SIZE, 0);
    numWrite = send(fd, "hello2", BUF_SIZE, 0);
    numWrite = send(fd, "hello3", BUF_SIZE, 0);
    numWrite = send(fd, "hello4", BUF_SIZE, 0);
    numWrite = send(fd, "hello5", BUF_SIZE, 0);
    numWrite = send(fd, "hello6", BUF_SIZE, 0);
}

server 端的结果可能为

buf: hello1
buf: hello4
buf: hello6

注意,在这里缺失的数量和位置都是不固定的,但是,如果在 send 前面添加 sleep,则 sleep 后一条必定可打印

1
2
3
4
5
6
7
8
9
10
11
12
13
/* client */
#define BUF_SIZE 4096
int main(int argc, char** argv){
    /* connect fd。省略。。。*/
    numWrite = send(fd, "hello1", BUF_SIZE, 0);
    sleep(1);
    numWrite = send(fd, "hello2", BUF_SIZE, 0);
    numWrite = send(fd, "hello3", BUF_SIZE, 0);
    numWrite = send(fd, "hello4", BUF_SIZE, 0);
    sleep(1);
    numWrite = send(fd, "hello5", BUF_SIZE, 0);
    numWrite = send(fd, "hello6", BUF_SIZE, 0);
}

则 server 端打印中必有

1
2
3
buf: hello1
buf: hello2
buf: hello5

其他项仍不固定。

在这里要明确一个关键的概念:

TCP 是面向流的协议,它发送的不是数据包。

之所以出现上面的问题,就是因为我们把 TCP 理解成发送数据包,误以为 send 和 recv 是一一对应的,send 发送一个数据包,recv 接收一个数据包。这是错误的理解!

事实上,在内核的 TCP 机制中,TCP 拥有两个缓冲区,一个用来发送,一个用来接收。

  1. 客户端调用 send 时,若数据成功发送到缓冲区(若缓冲区满则阻塞),send 返回,应用程序认为数据已经成功发送。此时,数据交由内核处理,应用程序不再过问。内核根据 TCP 协议的机制,判断何时将缓冲区内容打包发送到服务端。

  2. 服务端调用 recv,将内核接收缓冲区中的内容读入,读取后,应用程序解包数据。

注意:频繁调用 send,或网络通信质量不佳,会导致客户端往发送缓冲区填写多个数据(多个 hello),服务端调用 recv 一次性读入 BUF_SIZE 大小的数据,导致一次性读入多个数据包(多个 hello),这就是粘包

修改程序,打印 numRead 和 numWrite 值。调试 recv,查看 buf 内容:

"hello1/000hello2/000/hello3/000"

buf 内容证明了上面的猜测,由于’/000’导致 printf 只打印了第一个字符串,才出现这样的结果。