socket

Posted by YuCong on February 24, 2021

c++ 套接字网络编程

[toc]

socket

Created 2021.03.06 by William Yu; Last modified: 2021.03.06-V1.0.2

Contact: windmillyucong@163.com

Copyleft! 2021 William Yu. Some rights reserved.


c++ 网络编程,又称为套接字编程

References

  • https://www.gta.ufrj.br/ensino/eel878/sockets/index.html
  • https://www.cnblogs.com/DOMLX/p/9663167.html
  • 《c++网络编程》
  • https://zhuanlan.zhihu.com/p/119085959

1. 网络通信原理

  • AB通信的过程为:
    • A的应用层 -> A的传输层(TCP/UDP)-> A的网络层(IPV4,IPV6)-> A的底层硬件(物理信号)->
    • B的底层硬件 -> B的网络层 -> B 的传输层 -> B的应用层
  • socket工作与应用层和传输层之间
  • TCP和UDP的区别:
    • 连接和应用层数据的处理和发送方式

2. TCP

三个步骤

  • 建立链接
  • 收发数据
  • 断开链接

建立链接:TCP三次握手

  • 客户端为closed状态,服务端为listen状态

  • 客户端发起连接,(socket中的connent()函数 ),客户端向服务器发送SYN包

  • SYN包非常小,不含实际数据

  • 客户端状态由closed切换为syn_sent

  • 服务端接受syn

    • 服务端由listen转为syn_rcvd
    • 返回ack包和一个新的syn包
  • 客户端接受服务端返回的ack和syn包,并对syn返回ack包

    • 客户端由从SYN_SENT切换至ESTABLISHED,该状态表示可以传输数据了。
  • 服务端收到ACK包,成功建立连接,accept函数返回出客户端套接字。

    • 此时服务端状态从SYN_RCVD切换至ESTABLISHED
  • 客户请求服务器,服务器回复客户
  • 客户收到通知,确认链接建立,回复服务器
  • 服务器收到通知,确认链接建立

收发数据

  • 连接建立之后,通过客户端套接字收发数据

断开连接:TCP四次握手

  • 双方中有一方(假设为A,另一方为B)主动关闭连接(调用close,或者其进程本身被终止等情况),则其向B发送FIN包
  • 此时A从ESTABLISHED状态切换为FIN_WAIT_1状态
  • B接收到FIN包,并发送ACK包
  • 此时B从ESTABLISHED状态切换为CLOSE_WAIT状态
  • A接收到ACK包
  • 此时A从FIN_WAIT_1状态切换为FIN_WAIT_2状态
  • 一段时间后,B调用自身的close函数,发送FIN包
  • 此时B从CLOSE_WAIT状态切换为LAST_ACK状态
  • A接收到FIN包,并发送ACK包
  • 此时A从FIN_WAIT_2状态切换为TIME_WAIT状态
  • B接收到ACK包,关闭连接
  • 此时B从LAST_ACK状态切换为CLOSED状态
  • A等待一段时间(两倍的最长生命周期)后,关闭连接
  • 此时A从TIME_WAIT状态切换为CLOSED状态

3. socket API

socket()

  • 用于创建套接字描述符 sockfd
1
2
#include <sys/socket.h>
int socket(int family, int type, int protocol);
参数
  • family

指明要创建的协议族

1
2
 AF_INET   ipv4协议
 AF_INET6  ipv6协议
  • type

指明套接字类型

1
2
3
4
SOCK_STREAM:字节流套接字,适用于TCPSCTP协议
SOCK_DGRAM:数据报套接字,适用于UDP协议
SOCK_SEQPACKET:有序分组套接字,适用于SCTP协议
SOCK_RAW:原始套接字,适用于绕过传输层直接与网络层协议(IPv4/IPv6)通信
  • protocol

该参数用于指定协议类型。

如果是TCP协议的话就填写IPPROTO_TCP,UDP和SCTP协议类似。

也可以直接填写0,这样的话则会默认使用family参数和type参数组合制定的默认协议

(参照上面type参数的适用协议)

返回值

socket函数在成功时会返回套接字描述符,失败则返回-1。

失败的时候可以通过输出errno来详细查看具体错误类型。

errno 类

  • 通常一个内核函数运行出错的时候,它会定义全局变量errno并赋值。
  • 引入sys/errno.h头文件时便可以使用这个变量。并利用这个变量查看具体出错原因。

  • 一共有两种查看的方法:
    • 直接输出errno,根据输出的错误码查找解决方案
    • 借助strerror()函数,使用strerror(errno)得到一个具体描述其错误的字符串
1
2
3
4
5
6
7
8
9
#include <sys/errno.h>

...
  // 函数里面直接使用即可
  if (-1 == bind(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr))) {
    printf("Bind error(%d): %s\n", errno, strerror(errno));
    return -1;
  }
...

bind()

  • 服务端调用
  • 绑定 套接字和 ip::port
1
2
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *myaddr, socklen_t addrlen);

##### 参数

  • 套接字描述符
  • 套接字地址结构体
  • 套接字地址结构体的长度

套接字地址结构体

1
2
3
4
5
6
#include <sys/socket.h>
struct sockaddr {
    uint8_t     sa_len;
    sa_family_t sa_family;      // 地址协议族
    char        sa_data[14];    // 地址数据
};
  • 但是我们一般不使用这个结构体,而是使用一个更特定化的结构:IPv4套接字地址结构体

IPv4套接字地址结构体的定义:

1
2
3
4
5
6
7
8
9
10
11
12
#include <netinet/in.h>
struct in_addr {
	in_addr_t s_addr;  // 32位IPv4地址  
};

struct sockaddr_in {
    uint8_t         sin_len;        // 结构长度,非必需
    sa_family_t     sin_family;     // 地址族,一般为AF_****格式,常用的是AF_INET
    in_port_t       sin_port;       // 16位TCP或UDP端口号
    struct in_addr  sin_addr;       // 32位IPv4地址
    char            sin_zero[8];    // 保留数据段,一般置零
};

三个有用字段

  • sin_family
  • sin_addr
  • sin_port

listen()

  • 服务端调用
  • 开启套接字的监听状态,即将套接字由close转为listen
1
2
#include <sys/socket.h>
int listen(int socket, int backlog);
参数
  • sockfd 为要设置的套接字
  • backlog 为服务器处于listen状态下要维护的队列长度最大值
backlog参数详解
  • 服务器套接字处于LISTEN状态下所维护的未完成连接队列(SYN队列)已完成连接队列(Accept队列)的长度和的最大值
  • ↑ 这个是原本的意义,现在的backlog仅指Accept队列的最大长度,SYN队列的最大长度由系统的另一个变量决定
  • 这两个队列用于维护与客户端的连接,其中:
    • 客户端发送的SYN到达服务器之后,服务端返回SYN/ACK,并将该客户端放置SYN队列中(第一次+第二次握手)
    • 当服务端接收到客户端的ACK之后,完成握手,服务端将对应的连接从SYN队列中取出,放入Accept队列,等待服务器中的accept接收并处理其请求(第三次握手)
  • backlog参数大小的选择:

    • backlog是由程序员决定的,不过最后的队列长度其实是min(backlog, /proc/sys/net/core/somaxconn , net.ipv4.tcp_max_syn_backlog ),后者直接读取对应位置文件就有了。

      不过由于后者是可以修改的,故这里讨论的backlog实际上是这两个值的最小值。

      至于如何调参,可以参考这篇博客:

      https://ylgrgyq.github.io/2017/05/18/tcp-backlog/

      事实上backlog仅仅是与Accept队列的最大长度相关的参数,实际的队列最大长度视不同的操作系统而定。例如说MacOS上使用传统的Berkeley算法基于backlog参数进行计算,而Linux2.4.7上则是直接等于backlog+3

返回值
  • 若成功则返回0,否则返回-1并置相应的errno

connect()

  • 供客户端调用
  • 连接目标是 服务器(绑定了指定IP和port并且处于LISTEN状态的服务器)
函数原型
1
2
#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr *myaddr, socklen_t addrlen);
参数
  • 客户端套接字
  • 第二个参数为用于指定服务端的ip和port的套接字地址结构体
  • 第三个参数为该结构体的长度。
参数的配置与使用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
  // 配置
  bzero(&server_addr, sizeof(server_addr));
  server_addr.sin_family = AF_INET;
  inet_pton(AF_INET, kDefaultServerIp.c_str(), &server_addr.sin_addr);
  server_addr.sin_port = htons(kDefaultPort);

  // 创建链接
  if (-1 == connect(sockfd, (struct sockaddr *)(&server_addr),
                    sizeof(struct sockaddr))) {
    fprintf(stderr, "Connect error:%s\n", strerror(errno));
    close(sockfd);
    sockfd = 0;
    return -1;
  }
补充说明:IP地址格式转换函数
  • IP地址格式
    • 表达格式:字符串“172.0.0.1”
    • 数值格式:可以存入套接字地址结构体的格式,整型
函数原型
1
2
3
#include <arpa/inet.h>
int inet_pton(int family, const char *strptr, void *addrptr);
const char *inet_ntop(int family, const void *addrptr, char *strptr, size_t len);
参数

inet_pton()函数

  • 用于将IP地址从表达格式转换为数值格式
    • 第一个参数指定协议族(AF_INET或AF_INET6)
    • 第二个参数指定要转换的表达格式的IP地址
    • 第三个参数指定用于存储转换结果的指针
    • 对于返回结果而言:
      • 若转换成功则返回1
      • 若表达格式的IP地址格式有误则返回0
      • 若出错则返回-1

inet_ntop()函数

  • 用于将IP地址从数值格式转换为表达格式
    • 第一个参数指定协议族
    • 第二个参数指定要转换的数值格式的IP地址
    • 第三个参数指定用于存储转换结果的指针
    • 第四个参数指定第三个参数指向的空间的大小,用于防止缓存区溢出
    • 第四个参数可以使用预设的变量:

      1
      2
      3
      
      #include <netinet/in.h>
      #define INET_ADDRSTRLEN    16  // IPv4地址的表达格式的长度
      #define INET6_ADDRSTRLEN   46  // IPv6地址的表达格式的长度
      
    • 对于返回结果而言
      • 若转换成功则返回指向返回结果的指针
      • 若出错则返回NULL
返回值
  • 若成功则返回0,否则返回-1并置相应的errno
connect 异常

connect函数会出错的几种情况:

  • 若客户端在发送SYN包之后长时间没有收到响应,则返回ETIMEOUT错误

    • 一般而言,如果长时间没有收到响应,客户端会重发SYN包,若超过一定次数重发仍没响应的话则会返回该错误

    • 可能的原因是目标服务端的IP地址不存在

  • 若客户端在发送SYN包之后收到的是RST包的话,则会立刻返回ECONNREFUSED错误

    • 当客户端的SYN包到达目标机之后,但目标机的对应端口并没有正在LISTEN的套接字,那么目标机会发一个RST包给客户端

    • 可能的原因是目标服务端没有运行,或者没运行在客户端知道的端口上

  • 若客户端在发送SYN包的时候在中间的某一台路由器上发生ICMP错误,则会发生EHOSTUNREACHENETUNREACH错误

    • 事实上跟处理未响应一样,为了排除偶然因素,客户端遇到这个问题的时候会保存内核信息,隔一段时间之后再重发SYN包,在多次发送失败之后才会报错
    • 路由器发生ICMP错误的原因是,路由器上根据目标IP查找转发表但查不到针对目标IP应该如何转发,则会发生ICMP错误
    • 可能的原因是目标服务端的IP地址不可达,或者路由器配置错误,也有可能是因为电波干扰等随机因素导致数据包错误,进而导致路由无法转发
注意:异常处理
  • 由于connect函数在发送SYN包之后就会将自身的套接字从CLOSED状态置为SYN_SENT状态,故当connect报错之后需要主动将套接字状态置回CLOSED。此时需要通过调用close函数主动关闭套接字实现。
  • 故客户端代码需要注意手动关闭套接字:
1
2
3
4
5
6
if (-1 == connect(sockfd, (struct sockaddr*)&servaddr, sizeof(servaddr)))
{
    printf("Connect error(%d): %s\n", errno, strerror(errno));
    close(sockfd);        // 当connect出错时需要关闭套接字
    return -1;
}

accept()

  • 服务端调用
  • 用于跟客户端建立连接,并返回客户端套接字
  • Accept队列中pop出一个已完成的连接
  • 若Accept队列为空,则accept函数所在的进程阻塞
函数原型
1
2
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *cliaddr, socklen_t *addrlen);
参数
  • 第一个参数为服务端自身的套接字
  • 第二个参数用于接收客户端的套接字地址结构体
  • 第三个参数用于接收第二个参数的结构体的长度
返回值
  • 当accept函数成功拿到一个已完成连接时,其会返回该连接对应的客户端套接字描述符,用于后续的数据传输
  • 若发生错误则返回-1并置相应的errno

recv() send()

  • recv函数用于通过套接字接收数据
  • send函数用于通过套接字发送数据
函数原型
1
2
3
#include <sys/socket.h>
ssize_t recv(int sockfd, void *buff, size_t nbytes, int flags);
ssize_t send(int sockfd, const void *buff, size_t nbytes, int flags);
参数
  • 第一个参数为要读写的套接字
  • 第二个参数指定要接收数据的空间的指针(recv)或要发送的数据(send)
  • 第三个参数指定最大读取的字节数(recv)或发送的数据的大小(send)
  • 第四个参数用于设置一些参数,默认为0
  • 目前用不到第四个参数,故暂时不做展开
返回值
  • recv()
    • 成功,返回所读取的字节数
    • 失败,返回-1,errno
  • send()
    • 成功,返回写入的字节数
    • 失败,返回-1,errno
    • 事实上,当返回值与nbytes不等时,也可以认为其出错

close()

  • 用于断开连接,关闭套接字,终止TCP连接
原型
1
2
#include <unistd.h>
int close(int sockfd);
返回值
  • 若close成功则返回0
  • 否则返回-1并置errno
常见的错误

关闭一个无效的套接字

4. Http服务器搭建

将返回按照http协议格式填充即可

1
2
3
4
5
6
7
void setResponse(char *buff) {
  bzero(buff, sizeof(buff));
  strcat(buff, "HTTP/1.1 200 OK\r\n");
  strcat(buff, "Connection: close\r\n");
  strcat(buff, "\r\n");
  strcat(buff, "<h1>Hello</h1>\n");
}

开启服务之后,可以浏览器访问 http://localhost:16555/

5. 压力测试

  • 服务器能力指标
    • 在大量请求下仍能正确响应请求
    • 大量请求:
      • 总的请求数多
      • 请求并发量大
  • 工具
    • Apache Bench
      • 模拟大量的HTTP请求,只能测试HTTP服务器

Apache Bench

  • 模拟大量的HTTP请求,只能测试HTTP服务器
Install
1
sudo apt-get install apache2-utils
命令
1
ab -c 10 -n 10000 http://127.0.0.1:16555/
1
2
-c 请求并发数
-n 总请求量
结果
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
$ ab -c 10000 -n 100000 -r "http://192.168.19.12:16555/"
...
Complete requests:      10000  # 总共测试数量
Failed requests:        3403   # 失败数量
   (Connect: 0, Receive: 11345, Length: 11345, Exceptions: 11345)
Write errors:           0
Total transferred:      4133096 bytes
HTML transferred:       563604 bytes
Requests per second:    3278.15 [#/sec] (mean)
Time per request:       3050.501 [ms] (mean)
Time per request:       0.305 [ms] (mean, across all concurrent requests)
Transfer rate:          132.31 [Kbytes/sec] received
Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0  481 1061.9    146    7392
Processing:    31 1730 3976.7    561   15361
Waiting:        0  476 319.3    468   10064
Total:        175 2210 3992.2    781   15361
Percentage of the requests served within a certain time (ms)
  50%    781
  66%    873
  75%   1166
  80%   1783
  90%   4747
  95%  15038
  98%  15076
  99%  15087
 100%  15361 (longest request)


Contact

Feel free to contact me windmillyucong@163.com anytime for anything.

License

Creative Commons BY-SA 4.0