socket通信基础
# socket通信基础
# 1、socket 介绍
- 所谓 socket(套接字),就是对网络中不同主机上的应用进程之间进行双向通信的端点的抽象。 一个套接字就是网络上进程通信的一端,提供了应用层进程利用网络协议交换数据的机制。从所处的地位来讲,套接字上联应用进程,下联网络协议栈,是应用程序通过网络协议进行通信的接口, 是应用程序与网络协议根进行交互的接口。
- socket 可以看成是两个网络应用程序进行通信时,各自通信连接中的端点,这是一个逻辑上的概念。它是网络环境中进程间通信的 API,也是可以被命名和寻址的通信端点,使用中的每一个套接字都有其类型和一个与之相连进程。通信时其中一个网络应用程序将要传输的一段信息写入它所在主机的 socket 中,该 socket 通过与网络接口卡(NIC)相连的传输介质将这段信息送到另外一台 主机的 socket 中,使对方能够接收到这段信息。socket 是由 IP 地址和端口结合的,提供向应用 层进程传送数据包的机制。
- socket 本身有“插座”的意思,在 Linux 环境下,用于表示进程间网络通信的特殊文件类型。本质为 内核借助缓冲区形成的伪文件。既然是文件,那么理所当然的,我们可以使用文件描述符引用套接字。与管道类似的,Linux 系统将其封装成文件的目的是为了统一接口,使得读写套接字和读写文件的操作一致。区别是管道主要应用于本地进程间通信,而套接字多应用于网络进程间数据的传递。
// 套接字通信分两部分:
- 服务器端:被动接受连接,一般不会主动发起连接
- 客户端:主动向服务器发起连接
socket是一套通信的接口,Linux 和 Windows 都有,但是有一些细微的差别。
2
3
4
# 2、字节序
# 2.1 简介
- 现代 CPU 的累加器一次都能装载(至少)4 字节(这里考虑 32 位机),即一个整数。那么这 4 字节在内存中排列的顺序将影响它被累加器装载成的整数的值,这就是字节序问题。在各种计算机体系结构中,对于字节、字等的存储机制有所不同,因而引发了计算机通信领域中一个很重要的问 题,即通信双方交流的信息单元(比特、字节、字、双字等等)应该以什么样的顺序进行传送。如果不达成一致的规则,通信双方将无法进行正确的编码/译码从而导致通信失败。
- 字节序,顾名思义字节的顺序,就是大于一个字节类型的数据在内存中的存放顺序(一个字节的数据当然就无需谈顺序的问题了)。
- 字节序分为大端字节序(Big-Endian) 和小端字节序(Little-Endian)。
- 大端字节序是指一个整数的最高位字节(23 ~ 31 bit)存储在内存的低地址处,低位字节(0 ~ 7 bit)存储在内存的高地址处;
- 小端字节序则是指整数的高位字节存储在内存的高地址处,而低位字节则存储在内存的低地 址处。
# 2.2 字节序举例
# 小端字节序
小端字节序则是指整数的高位字节存储在内存的高地址处,而低位字节则存储在内存的低地址处。
0x01 02 03 04 --- ff = 255 (16进制,一个字节最大表示到255)
内存的方向 ----->
内存的低位 -----> 内存的高位
那么按小端字节序数据在内存中的存储顺序就是 0x04 03 02 01
给一段数据:0x 11 22 33 44 12 34 56 78那么它的大端排序如下:
# 大端字节序
大端字节序是指一个整数的最高位字节(23 ~ 31 bit)存储在内存的低地址处,低位字节(0 ~ 7 bit)存储在内存的高地址处;
0x 01 02 03 04
内存的方向 ----->
内存的低位 -----> 内存的高位
那么按大端字节序数据在内存中的存储顺序就是 0x 01 02 03 04
给一段数据:0x 12 34 56 78 11 22 33 44那么它的大端排序如下:
//byteroder.c
/*
字节序:字节在内存中存储的顺序。
小端字节序:数据的高位字节存储在内存的高位地址,低位字节存储在内存的低位地址
大端字节序:数据的低位字节存储在内存的高位地址,高位字节存储在内存的低位地址
*/
// 通过代码检测当前主机的字节序
#include <stdio.h>
int main()
{
union
{
short value; // 2字节
char bytes[sizeof(short)]; // char(2)
} test;
test.value = 0x0102;
if ((test.bytes[0] == 1) && (test.bytes[1] == 2))
{
printf("大端字节序\n");
}
else if ((test.bytes[0] == 2) && (test.bytes[1] == 1))
{
printf("小端字节序\n");
}
else
{
printf("未知\n");
}
return 0;
}
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
30
31
32
33
34
35
- 编译运行上述代码鉴定了一下,我的电脑是小端字节序。😎
# 2.3、字节序转换函数
# 2.3.1 主机网络数据传递
当格式化的数据在两台使用不同字节序的主机之间直接传递时,接收端必然错误的解释之。解决问题的方法是:发送端总是把要发送的数据转换成大端字节序数据后再发送,而接收端知道对方传送过来的数据总是采用大端字节序,所以接收端可以根据自身采用的字节序决定是否对接收到的数据进行转换(小端机转换,大端机不转换)。
网络字节顺序是 TCP/IP 中规定好的一种数据表示格式,它与具体的 CPU 类型、操作系统等无关,从而可以保证数据在不同主机之间传输时能够被正确解释,网络字节顺序采用大端排序方式。
BSD Socket提供了封装好的转换接口,方便程序员使用。包括从主机字节序到网络字节序的转换函数:
htons
、htonl
;从网络字节序到主机字节序的转换函数:ntohs
、ntohl
。
h - host 主机,主机字节序
to - 转换成什么
n - network 网络字节序
s - short的缩写 unsigned short (2个字节,16位)
l - long的缩写 unsigned int (4个字节,32位)
2
3
4
5
# 2.3.2 函数
#include <arpa/inet.h>
// 转换端口
//端口就是16位的,所以转 s 就是转 端口
uint16_t htons(uint16_t hostshort); // 主机字节序 - 网络字节序
uint16_t ntohs(uint16_t netshort); // 主机字节序 - 网络字节序
// 转IP
// IP 地址是一个 32 位的二进制数,所以转 l 是转IP
uint32_t htonl(uint32_t hostlong); // 主机字节序 - 网络字节序
uint32_t ntohl(uint32_t netlong); // 主机字节序 - 网络字节序
2
3
4
5
6
7
8
9
10
11
//bytetrans.c
/*
网络通信时,需要将(这里我的主机是小端)主机字节序转换成网络字节序(大端),
另外一段获取到数据以后根据情况将网络字节序转换成主机字节序。
// 转换端口
uint16_t 其实就是 unsigned short ;
uint16_t htons(uint16_t hostshort); // 主机字节序 - 网络字节序
uint16_t ntohs(uint16_t netshort); // 主机字节序 - 网络字节序
// 转IP
uint32_t 其实就是 unsigned int;
uint32_t htonl(uint32_t hostlong); // 主机字节序 - 网络字节序
uint32_t ntohl(uint32_t netlong); // 主机字节序 - 网络字节序
*/
#include <stdio.h>
#include <arpa/inet.h>
int main()
{
// htons 转换端口
unsigned short a = 0x0102;
printf("a : %x\n", a);
unsigned short b = htons(a);
printf("b : %x\n", b);
printf("=======================\n");
// htonl 转换IP
char buf[4] = {192, 168, 1, 100};
// 数组空间是连续的,所以char buf就是占了4个字节空间,buf就是首地址,下面强转后再解引用
// 所以我们强转为int指针类型,再解引用,就是将地址中的值按照int型变量进行解释
int num = *(int *)buf;
printf("num : %d\n", num); // 1677830336 ,我们用int接收了这值
printf("num : %x\n", num); // 上面这个值的16进制就是6401a8c0
int sum = htonl(num);
// 以为sum是int,所以我们要用char指针去指向sum的空间就需要先进行类型转换
// 注意:是对地址转换,所以要加 &
unsigned char *p = (char *)∑
// 这里进行指针+1的操作必须加括号,不然就是解引用完再+1
printf("%d %d %d %d\n", *p, *(p + 1), *(p + 2), *(p + 3));
printf("=======================\n");
// ntohl
unsigned char buf1[4] = {1, 1, 168, 192};
int num1 = *(int *)buf1;
int sum1 = ntohl(num1);
unsigned char *p1 = (unsigned char *)&sum1;
printf("%d %d %d %d\n", *p1, *(p1 + 1), *(p1 + 2), *(p1 + 3));
printf("=======================\n");
// ntohs
unsigned short c = 0x0201;
printf("c : %x\n", c);
unsigned short d = ntohs(c);
printf("d : %x\n", d);
return 0;
}
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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
- 可以看到我们在经过转换后,我们的顺序都发送了翻转(仔细看代码)
# 3、socket地址
// socket地址其实是一个结构体,封装端口号和IP等信息。后面的socket相关的api中需要使用到这个socket地址。
// 客户端 -> 服务器(IP, Port)
2
# 3.1 通用 socket 地址
- socket 网络编程接口中表示 socket 地址的是结构体 sockaddr,其定义如下:
#include <bits/socket.h>
struct sockaddr {
sa_family_t sa_family;
char sa_data[14]; //14个字节
};
typedef unsigned short int sa_family_t;
2
3
4
5
6
7
- sa_family 成员是地址族类型(sa_family_t)的变量。地址族类型通常与协议族类型对应。常见的协议 族(protocol family,也称 domain)和对应的地址族入下所示:
协议族 | 地址族 | 描述 |
---|---|---|
PF_UNIX | AF_UNIX | UNIX本地域协议族 |
PF_INET | AF_INET | TCP/IPv4协议族 |
PF_INET6 | AF_INET6 | TCP/IPv6协议族 |
宏 PF_ 和 AF_** 都定义在
bits/socket.h 头文件
中,且后者与前者有完全相同的值,所以二者通常混用。sa_data 成员用于存放 socket 地址值。但是,不同的协议族的地址值具有不同的含义和长度,如下所示:
协议族 | 地址值含义和长度 |
---|---|
PF_UNIX | 文件的路径名,长度可达到108字节 |
PF_INET | 16 bit 端口号和 32 bit IPv4 地址,共 6 字节 (14-6 剩下的空间就空着) |
PF_INET6 | 16 bit 端口号,32 bit 流标识,128 bit IPv6 地址,32 bit 范围 ID,共 26 字节 |
- 由上表可知,14 字节的 sa_data 根本无法容纳多数协议族的地址值(PF_INET6就不行)。因此,Linux 定义了下面这个新的通用的 socket 地址结构体,这个结构体不仅提供了足够大的空间用于存放地址值,而且是内存对齐的。
#include <bits/socket.h>
struct sockaddr_storage
{
sa_family_t sa_family;
unsigned long int __ss_align; //用来对齐的
char __ss_padding[ 128 - sizeof(__ss_align) ];
};
typedef unsigned short int sa_family_t;
2
3
4
5
6
7
8
# 3.2 专用 socket 地址
- *很多网络编程函数诞生早于 IPv4 协议,那时候都使用的是 struct sockaddr 结构体,为了向前兼容,现 在sockaddr 退化成了(void )的作用,传递一个地址给函数,至于这个函数是 sockaddr_in 还是 sockaddr_in6,由地址族确定,然后函数内部再强制类型转化为所需的地址类型。
- UNIX 本地域协议族使用如下专用的 socket 地址结构体:
#include <sys/un.h>
struct sockaddr_un
{
sa_family_t sin_family;
char sun_path[108];
};
2
3
4
5
6
- TCP/IP 协议族有 sockaddr_in 和 sockaddr_in6 两个专用的 socket 地址结构体,它们分别用于 IPv4 和 IPv6:
#include <netinet/in.h>
struct sockaddr_in
{
sa_family_t sin_family; /* __SOCKADDR_COMMON(sin_) */
in_port_t sin_port; /* Port number. */ 端口号
struct in_addr sin_addr; /* Internet address. */ IP地址
/* Pad to size of `struct sockaddr'. */
unsigned char sin_zero[sizeof (struct sockaddr) - __SOCKADDR_COMMON_SIZE -
sizeof (in_port_t) - sizeof (struct in_addr)];
};
struct in_addr
{
in_addr_t s_addr;
};
struct sockaddr_in6
{
sa_family_t sin6_family;
in_port_t sin6_port; /* Transport layer port # */
uint32_t sin6_flowinfo; /* IPv6 flow information */
struct in6_addr sin6_addr; /* IPv6 address */
uint32_t sin6_scope_id; /* IPv6 scope-id */
};
typedef unsigned short uint16_t;
typedef unsigned int uint32_t;
typedef uint16_t in_port_t;
typedef uint32_t in_addr_t;
#define __SOCKADDR_COMMON_SIZE (sizeof (unsigned short int))
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
30
- 所有专用 socket 地址(以及 sockaddr_storage)类型的变量在实际使用时都需要转化为通用 socket 地址类型 sockaddr(强制转化即可),因为所有 socket 编程接口使用的地址参数类型都是 sockaddr。
# 4、IP地址转换
- 字符串ip-整数 ,主机、网络字节序的转换
# 4.1 简介
- 通常,人们习惯用可读性好的字符串来表示 IP 地址,比如用点分十进制字符串表示 IPv4 地址,以及用 十六进制字符串表示 IPv6 地址。但编程中我们需要先把它们转化为整数(二进制数)方能使用。而记录日志时则相反,我们要把整数表示的 IP 地址转化为可读的字符串。下面 3 个函数可用于用点分十进制字 符串表示的 IPv4 地址和用网络字节序整数表示的 IPv4 地址之间的转换:
#include <arpa/inet.h>
in_addr_t inet_addr(const char *cp);
int inet_aton(const char *cp, struct in_addr *inp);
char *inet_ntoa(struct in_addr in);
2
3
4
- 下面这对更新的函数也能完成前面 3 个函数同样的功能,并且它们同时适用 IPv4 地址和 IPv6 地址:
#include <arpa/inet.h>
// p:点分十进制的IP字符串,n:表示network,网络字节序的整数
int inet_pton(int af, const char *src, void *dst);
af:地址族: AF_INET AF_INET6
src:需要转换的点分十进制的IP字符串
dst:转换后的结果保存在这个里面
// 将网络字节序的整数,转换成点分十进制的IP地址字符串
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
af: 地址族: AF_INET AF_INET6
src: 要转换的ip的整数的地址
dst: 转换成IP地址字符串保存的地方
size:第三个参数的大小(数组的大小)
返回值:返回转换后的数据的地址(字符串),和 dst 是一样的
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 4.2 代码
//iptrans.c
#include <stdio.h>
#include <arpa/inet.h>
int main()
{
// 创建一个ip字符串,点分十进制的IP地址字符串
char buf[] = "192.168.1.4";
unsigned int num = 0;
// 将点分十进制的IP字符串转换成网络字节序的整数
inet_pton(AF_INET, buf, &num);
unsigned char *p = (unsigned char *)#
// 一个字节,一个字节打印
printf("%d %d %d %d\n", *p, *(p + 1), *(p + 2), *(p + 3));
// 将网络字节序的IP整数转换成点分十进制的IP字符串
// 之所以是16,是因为点分十进制最多数字就 12个,再加3个点和一个字符串结束符,所以总共16
char ip[16] = "";
const char *str = inet_ntop(AF_INET, &num, ip, 16);
printf("str : %s\n", str);
printf("ip : %s\n", str);
printf("%d\n", ip == str);
return 0;
}
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
- 🟥: 证明inte_ntop 的返回值就是 inte_ntop 第三个参数的地址;
# 5、TCP 通信流程
// TCP 和 UDP -> 传输层的协议
UDP:用户数据报协议,面向无连接,可以单播,多播,广播面向数据报,不可靠
TCP:传输控制协议,面向连接的,可靠的,基于字节流,仅支持单播传输
UDP TCP
是否创建连接 无连接 面向连接
是否可靠 不可靠 可靠的
连接的对象个数 一对一、一对多、多对一、多对多 支持一对一
传输的方式 面向数据报 面向字节流
首部开销 8个字节 最少20个字节
适用场景 实时应用(视频会议,直播) 可靠性高的应用(文件传输)
2
3
4
5
6
7
8
9
10
- 调用这个listen()底层会产生两个队列,一个是未连接的队列,一个是已连接的
- socket返回的文件描述符是专门用来监听的,accept返回的文件描述符才是用了通信的
// TCP 通信的流程
// 服务器端 (被动接受连接的角色)
1. 创建一个用于监听的套接字
- 监听:监听有客户端的连接
- 套接字:这个套接字其实就是一个文件描述符
2. 将这个监听文件描述符和本地的IP和端口绑定(IP和端口就是服务器的地址信息)
- 客户端连接服务器的时候使用的就是这个IP和端口
3. 设置监听,监听的fd开始工作
4. 阻塞等待,当有客户端发起连接,解除阻塞,接受客户端的连接,会得到一个和客户端通信的套接字(fd)
5. 通信
- 接收数据
- 发送数据
6. 通信结束,断开连接
2
3
4
5
6
7
8
9
10
11
12
13
// 客户端
1. 创建一个用于通信的套接字(fd)
2. 连接服务器,需要指定连接的服务器的 IP 和 端口
3. 连接成功了,客户端可以直接和服务器通信
- 接收数据
- 发送数据
4. 通信结束,断开连接
2
3
4
5
6
7
# 6、套接字函数
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h> // 包含了这个头文件,上面两个就可以省略
int socket(int domain, int type, int protocol);
- 功能:创建一个套接字
- 参数:
- domain: 协议族
AF_INET : ipv4
AF_INET6 : ipv6
AF_UNIX, AF_LOCAL : 本地套接字通信(进程间通信)
- type: 通信过程中使用的协议类型
SOCK_STREAM : 流式协议
SOCK_DGRAM : 报式协议
- protocol : 具体的一个协议。一般写0
- 如果第二个参数是SOCK_STREAM : 流式协议默认使用 TCP
- SOCK_DGRAM : 报式协议默认使用 UDP
- 返回值:
- 成功:返回文件描述符,操作的就是内核缓冲区。
- 失败:-1
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen); // socket命名
- 功能:绑定,将fd 和本地的IP + 端口进行绑定
- 参数:
- sockfd : 通过socket函数得到的文件描述符
- addr : 需要绑定的socket地址,这个地址封装了ip和端口号的信息
- addrlen : 第二个参数结构体占的内存大小
int listen(int sockfd, int backlog); // /proc/sys/net/core/somaxconn
- 功能:监听这个socket上的连接
- 参数:
- sockfd : 通过socket()函数得到的文件描述符
- backlog : 未连接的和已经连接的和的最大值, 5
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
- 功能:接收客户端连接,默认是一个阻塞的函数,阻塞等待客户端连接
- 参数:
- sockfd : 用于监听的文件描述符
- addr : 传出参数,记录了连接成功后客户端的地址信息(ip,port)
- addrlen : 指定第二个参数的对应的内存大小
- 返回值:
- 成功 :用于通信的文件描述符
- -1 : 失败
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
- 功能: 客户端连接服务器
- 参数:
- sockfd : 用于通信的文件描述符
- addr : 客户端要连接的服务器的地址信息
- addrlen : 第二个参数的内存大小
- 返回值:成功 0, 失败 -1
ssize_t write(int fd, const void *buf, size_t count); // 写数据
ssize_t read(int fd, void *buf, size_t count); // 读数据
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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
我查看完后发现是4096,但是老师说给5就可以了,因为如果已连接很快就会让accept()处理
注意
connect()
和accept()
第三个参数类型的区别:accept
是socklen_t *addrlen
指针;connect
是socklen_t addrlen
;
bind
、accept
、connect
都需要强转类型
# 7、TCP通信实现
# 7.1 服务器端
//server.c
// TCP 通信的服务器端
#include <stdio.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
int main()
{
// 1.创建socket(用于监听的套接字)
// 我们要实现TCP通信,所以用 SOCK_STREAM
int lfd = socket(AF_INET, SOCK_STREAM, 0);
if (lfd == -1)
{
perror("socket");
exit(-1);
}
// 绑定
struct sockaddr_in saddr;
saddr.sin_family = AF_INET;
// 将点分十进制的IP字符串转换为网络字节序的整数,然后保存到 saddr.sin_addr.s_addr里
// inet_pton(AF_INET, "192.168.193.128", saddr.sin_addr.s_addr);
// 下面的写法是服务器开发才可以的写法(表示任意地址的意思)
// 这样写的话客户端访问过来的时候随便用哪个IP都可以访问到计算机
saddr.sin_addr.s_addr = INADDR_ANY; // 0.0.0.0
// 端口号我们可以自己随便写,但这里是主机字节序,要转为网络字节序
saddr.sin_port = htons(9999);
// 我们上面用的是 sockaddr_in 型的结构体,所以这里要强转
// 注意结构体的结构体名不能直接当作地址使用,所以要加 &
int ret = bind(lfd, (struct sockaddr *)&saddr, sizeof(saddr));
if (ret == -1)
{
perror("bind");
exit(-1);
}
// 3.监听
ret = listen(lfd, 8);
if (ret == -1)
{
perror("listen");
exit(-1);
}
// 4.接收客户端连接
// 创建一个 sockaddr_in 型的socket 地址结构体来接收客户端的地址信息
struct sockaddr_in clientaddr;
int len = sizeof(clientaddr);
int cfd = accept(lfd, (struct sockaddr *)&clientaddr, &len);
if (cfd == -1)
{
perror("accept");
exit(-1);
}
// 输出客户端的信息
char clientIP[16];
// 将网络字节序的整数,转换成点分十进制的IP地址字符串
inet_ntop(AF_INET, &clientaddr.sin_addr.s_addr, clientIP, sizeof(clientIP));
// 将网络字节序转为主机的字节序
unsigned short clientPort = ntohs(clientaddr.sin_port);
printf("client ip is %s, port is %d\n", clientIP, clientPort);
// 5.通信
char recvBuf[1024] = {0};
while (1)
{
// 获取客户端的数据
int num = read(cfd, recvBuf, sizeof(recvBuf));
if (num == -1)
{
perror("read");
exit(-1);
}
else if (num > 0)
{
printf("recv client data : %s\n", recvBuf);
}
else if (num == 0)
{
// 表示客户端断开连接
// 类似于管道,写入端计数为0,管道内数据为0,那么返回值就为0
printf("clinet closed...");
break;
}
char *data = "hello,i am server";
// 给客户端发送数据
write(cfd, data, strlen(data));
}
// 关闭文件描述符
close(cfd);
close(lfd);
return 0;
}
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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
# 7.2 客户端
//client.c
// TCP通信的客户端
#include <stdio.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
int main()
{
// 1.创建套接字
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd == -1)
{
perror("socket");
exit(-1);
}
// 2.连接服务器端
struct sockaddr_in serveraddr;
serveraddr.sin_family = AF_INET;
// 这里的 192.168.186.142 是我写代码时本地的ip
inet_pton(AF_INET, "192.168.186.142", &serveraddr.sin_addr.s_addr);
// 因为我们服务端的端口号给的是9999,所以这里也要是9999
serveraddr.sin_port = htons(9999);
int ret = connect(fd, (struct sockaddr *)&serveraddr, sizeof(serveraddr));
if (ret == -1)
{
perror("connect");
exit(-1);
}
// 3. 通信
char recvBuf[1024] = {0};
while (1)
{
char *data = "hello,i am client";
// 给客户端发送数据
write(fd, data, strlen(data));
sleep(1);
int len = read(fd, recvBuf, sizeof(recvBuf));
if (len == -1)
{
perror("read");
exit(-1);
}
else if (len > 0)
{
printf("recv server data : %s\n", recvBuf);
}
else if (len == 0)
{
// 表示服务器端断开连接
printf("server closed...");
break;
}
}
// 关闭连接
close(fd);
return 0;
}
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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
# 7.3 运行结果分析
🟩:客户端绑定的端口是随机的,客户端里面指定的9999是去连接服务器的这个端口。这里的
34010
就是系统随机分配的我们必须先运行服务器端,再运行客户端,(如果先运行客户端,直接就是找不到的,然后就运行结束了),而先运行服务器端,服务器会被阻塞在 accept() 等待客户端建立连接
🟨建立通信后,如果我们是先终止的客户端,服务端也会终止并输出🟨内容(这里是我们代码里这么写,不是说客户端终止服务器端就会终止),这里是因为 我们
ctrl+c
关掉客户端,客户端会发一些其他的通信的东西,所以服务器端read返回的是 -1但是如果我们是去终止的服务器端,那客户端则是输出
server closed...
,并终止
# 7.4 键盘输入 + 回射
- 要求客户端键盘输入数据发送给服务器端,服务器端将接收到得数据又发送回给客户端(回射)
// TCP 通信的服务器端
#include <stdio.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
int main()
{
// 1.创建socket(用于监听的套接字)
int lfd = socket(AF_INET, SOCK_STREAM, 0);
if (lfd == -1)
{
perror("socket");
exit(-1);
}
// 2.绑定
struct sockaddr_in saddr;
saddr.sin_family = AF_INET;
// inet_pton(AF_INET, "192.168.193.128", saddr.sin_addr.s_addr);
saddr.sin_addr.s_addr = INADDR_ANY; // 0.0.0.0
saddr.sin_port = htons(9999);
int ret = bind(lfd, (struct sockaddr *)&saddr, sizeof(saddr));
if (ret == -1)
{
perror("bind");
exit(-1);
}
// 3.监听
ret = listen(lfd, 8);
if (ret == -1)
{
perror("listen");
exit(-1);
}
// 4.接收客户端连接
struct sockaddr_in clientaddr;
int len = sizeof(clientaddr);
int cfd = accept(lfd, (struct sockaddr *)&clientaddr, &len);
if (cfd == -1)
{
perror("accept");
exit(-1);
}
// 输出客户端的信息
char clientIP[16];
inet_ntop(AF_INET, &clientaddr.sin_addr.s_addr, clientIP, sizeof(clientIP));
unsigned short clientPort = ntohs(clientaddr.sin_port);
printf("client ip is %s, port is %d\n", clientIP, clientPort);
// 5.通信
char recvBuf[1024] = {0};
while (1)
{
// 获取客户端的数据
int num = read(cfd, recvBuf, sizeof(recvBuf));
if (num == -1)
{
perror("read");
exit(-1);
}
else if (num > 0)
{
printf("我接受到了客户端的数据 : %s\n", recvBuf);
}
else if (num == 0)
{
// 表示客户端断开连接
printf("clinet closed...\n");
break;
}
char *data = recvBuf;
// 给客户端发送数据(实现回射服务器)
write(cfd, data, strlen(data));
}
// 关闭文件描述符
close(cfd);
close(lfd);
return 0;
}
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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
// TCP通信的客户端
#include <stdio.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
int main()
{
// 1.创建套接字
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd == -1)
{
perror("socket");
exit(-1);
}
// 2.连接服务器端
struct sockaddr_in serveraddr;
serveraddr.sin_family = AF_INET;
// 这里的 192.168.186.142 是我写代码时本地的ip
inet_pton(AF_INET, "192.168.186.142", &serveraddr.sin_addr.s_addr);
serveraddr.sin_port = htons(9999);
int ret = connect(fd, (struct sockaddr *)&serveraddr, sizeof(serveraddr));
if (ret == -1)
{
perror("connect");
exit(-1);
}
// 3. 通信
char recvBuf[1024] = {0};
while (1)
{
// 从键盘输入,给客户端发送数据
char data[1024];
memset(data, 0, sizeof data);
printf("请输入发送数据:");
scanf("%s", data);
write(fd, data, strlen(data));
sleep(1);
int len = read(fd, recvBuf, sizeof(recvBuf));
if (len == -1)
{
perror("read");
exit(-1);
}
else if (len > 0)
{
printf("我接受到了回射服务器的返回的数据 : %s\n", recvBuf);
}
else if (len == 0)
{
// 表示服务器端断开连接
printf("server closed...\n");
break;
}
}
// 关闭连接
close(fd);
return 0;
}
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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
# 7.5 疑惑?
# 问题一
- 我们服务器端的IP定义为
INADDR_ANY
( 0.0.0.0 ),不是应该是所有的都能连接吗 ? 为什么我们客户端设置要连接的 IP时,还是要写本地的 IP?
# 问题二
- 我这里也是终止的客户端,为什么服务器端退出读写循环打印的是
clinet closed...
(即read返回值为0
),而不是-1
?
# 8、TCP 三次握手
- TCP 是一种面向连接的单播协议,在发送数据前,通信双方必须在彼此间建立一条连接。所谓的“连接”,其实是客户端和服务器的内存里保存的一份关于对方的信息,如 IP 地址、端口号等。
- TCP 可以看成是一种字节流,它会处理 IP 层或以下的层的丢包、重复以及错误问题。在连接的建立过程中,双方需要交换一些连接的参数。这些参数可以放在 TCP 头部。
- TCP 提供了一种可靠、面向连接、字节流、传输层的服务,采用
三次握手
建立一个连接。采用四次挥手
来关闭一个连接。 - 三次握手的目的是保证双方互相之间建立了连接。
- 三次握手发送在客户端连接的时候,当调用
connect()
,底层会通过TCP协议进行三次握手。
16 位端口号(port number):告知主机报文段是来自哪里(源端口)以及传给哪个上层协议或 应用程序(目的端口)的。进行 TCP 通信时,客户端通常使用系统自动选择的临时端口号。
32 位序号(sequence number):一次 TCP 通信(从 TCP 连接建立到断开)过程中某一个传输方向上的字节流的每个字节的编号。假设主机 A 和主机 B 进行 TCP 通信,A 发送给 B 的第一个 TCP 报文段中,序号值被系统初始化为某个随机值 ISN(Initial Sequence Number,初始序号值)。那么在该传输方向上(从 A 到 B),后续的 TCP 报文段中序号值将被系统设置成 ISN 加上该报文段所携带数据的第一个字节在整个字节流中的偏移。例如,某个 TCP 报文段传送的数据是字节流中的第 1025 ~ 2048 字节,那么该报文段的序号值就是 ISN + 1025。另外一个传输方向(从 B 到 A)的 TCP 报文段的序号值也具有相同的含义。
32 位确认号(acknowledgement number):用作对另一方发送来的 TCP 报文段的响应。其值是 收到的 TCP 报文段的序号值 + 标志位长度(SYN,FIN) + 数据长度 。假设主机 A 和主机 B 进行 TCP 通信,那么 A 发送出的 TCP 报文段不仅携带自己的序号,而且包含对 B 发送来的 TCP 报文段的确认号。反之,B 发送出的 TCP 报文段也同样携带自己的序号和对 A 发送来的报文段的确认序号。
4 位头部长度(head length):标识该 TCP 头部有多少个 32 bit(4 字节)。因为 4 位最大能表示 15,所以 TCP 头部最长是60 字节。
6 位标志位包含如下几项:
- URG 标志,表示紧急指针(urgent pointer)是否有效。
- ACK 标志,表示确认号是否有效。我们称携带 ACK 标志的 TCP 报文段为确认报文段。
- PSH 标志,提示接收端应用程序应该立即从 TCP 接收缓冲区中读走数据,为接收后续数据腾出空间(如果应用程序不将接收到的数据读走,它们就会一直停留在 TCP 接收缓冲区中)。
- RST 标志,表示要求对方重新建立连接。我们称携带 RST 标志的 TCP 报文段为复位报文段。
- SYN 标志,表示请求建立一个连接。我们称携带 SYN 标志的 TCP 报文段为同步报文段。
- FIN 标志,表示通知对方本端要关闭连接了。我们称携带 FIN 标志的 TCP 报文段为结束报文段。
- 16 位窗口大小(window size):是 TCP 流量控制的一个手段。这里说的窗口,指的是接收 通告窗口(Receiver Window,RWND)。它告诉对方本端的 TCP 接收缓冲区还能容纳多少 字节的数据,这样对方就可以控制发送数据的速度。
- 16 位校验和(TCP checksum):由发送端填充,接收端对 TCP 报文段执行 CRC 算法以校验 TCP 报文段在传输过程中是否损坏。注意,这个校验不仅包括 TCP 头部,也包括数据部分。 这也是 TCP 可靠传输的一个重要保障。
- 16 位紧急指针(urgent pointer):是一个正的偏移量。它和序号字段的值相加表示最后一 个紧急数据的下一个字节的序号。因此,确切地说,这个字段是紧急指针相对当前序号的偏 移,不妨称之为紧急偏移。TCP 的紧急指针是发送端向接收端发送紧急数据的方法。
client server
c:能听得到我说话吗?
s:可以,你能听得到我说话吗?
c:我也可以
//这样就确定了双方都能发送和接收
2
3
4
5
第一次握手:
1.客户端将 SYN 标志位置为 1
2.生成一个随机的32位的序号 seq = J , 这个序号后边是可以携带数据(数据的大小)
第二次握手:
1.服务器端接收客户端的连接:ACK = 1
2.服务器会回发一个确认序号: ack = 客户端的序号 + 数据长度 + SYN/FIN(按一个字节算)
3.服务器端会向客户端发起连接请求: SYN = 1
4.服务器会生成一个随机序号:seq = k
第三次握手:
1.客户端应答服务器的连接请求:ACK =1
2.客户端回复收到了服务器端的数据:ack=服务器的序号 + 数据长度 + SYN/FIN(按一个字节算)
2
3
4
5
6
7
8
9
10
11
# 9、TCP 滑动窗口
- 滑动窗口(Sliding window)是一种流量控制技术。早期的网络通信中,通信双方不会考虑网络的拥挤情况直接发送数据。由于大家不知道网络拥塞状况,同时发送数据,导致中间节点阻塞掉包, 谁也发不了数据,所以就有了滑动窗口机制来解决此问题。滑动窗口协议是用来改善吞吐量的一种技术,即容许发送方在接收任何应答之前传送附加的包。接收方告诉发送方在某一时刻能送多少包 (称窗口尺寸)。
- TCP 中采用滑动窗口来进行传输控制,滑动窗口的大小意味着接收方还有多大的缓冲区可以用于接收数据。发送方可以通过滑动窗口的大小来确定应该发送多少字节的数据。当滑动窗口为 0 时,发送方一般不能再发送数据报。
- 滑动窗口是 TCP 中实现诸如 ACK 确认、流量控制、拥塞控制的承载结构。
窗口理解为缓冲区的大小
滑动窗口的大小会随着发送数据和接收数据而改变
通信的双方都有发送缓冲区和接收数据的缓冲区
服务器:
发送缓冲区(发送缓冲区的窗口)
接收缓冲区(接收缓冲区的窗口)
客户端:
发送缓冲区(发送缓冲区的窗口)
接收缓冲区(接收缓冲区的窗口)
2
3
4
5
6
7
8
9
发送方的缓冲区:
白色格子:空闲的空间
灰色格子:数据已经被发送出去了,但是还没有被接收(就是还没收到接收方发回的ACK表示被接收)
紫色格子:还没有发送出去的数据
接收方的缓冲区:
白色格子:空闲的空间
紫色格子:已经接收的数据
2
3
4
5
6
7
8
# mss:Maximum Segment Size (一条数据的最大的数据量)
# win:滑动窗口
1.客户端向服务器发起连接,客户端的滑动窗口是4096,一次发送的最大数据量是1460
2.服务器接收连接情况,告诉客户端服务器的窗口大小是6144,一次发送的最大数据量是1024
3.第三次握手
4.4-9 客户端连续给服务器发送了6K的数据,每次发送 1K
5. 第10次,服务器告诉客户端:发送的6k数据已经接收到,存储在缓冲区中,缓冲区数据已经处理了2k,窗口大小是2k
6. 第11次,服务器告诉客户端:发送的6k数据已经接收到,存储在缓冲区中,缓冲区数据已经处理了4k,窗口大小是4k
7. 第12次,客户端给服务器发送了1k的数据
8. 第13次,客户端主动请求和服务器断开连接,并且给服务器发送了1k的数据
9. 第14次,服务器回复ACK 8194,(7169+1024 = 8193,之所以是8194是因为接收到发送方的FIN ,所以又加了1)
- a:同意断开连接的请求
- b:告诉客户端已经接受到方才发的2k的数据
- c:滑动窗口2k
10. 第15、16次,通知客户端滑动窗口的大小
11. 第17次,第三次挥手服务器给客户端发送FIN,请求断开连接
12. 第18次,第4次挥手客户端同意了服务器端的断开请求
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
- 第一次握手还不能携带数据(不能携带指的是tcp报文的数据,不是报文头的数据),因为我们还没经过3次握手,建立连接,但是第三次握手其实发送方已经可以携带数据了,因为已经发送方已经确定了能发送能接收了
# 10、TCP 四次挥手
四次挥手发生在断开连接的时候,在程序中当调用了close()会使TCP协议进行4次挥手
客户端和服务器端都可以主动发起断开连接,谁先调用 close()谁就是发起。
因为在TCP连接的时候,采用三次握手建立的连接是双向的,在断开的时候需要双向断开。
2
3
client server
c:不跟你玩了!!!
s:好呀?!😥
(此时s还可以跟c说其他话)
s:既然如此,那我也不和你玩了😡
c:不玩就不玩
//你不和我玩,我也不和你玩,这就是4次挥手(先提前绝交的可以是任意一方)
2
3
4
5
6
7
- 上图第四次挥手的 K+1 应该是 N+1,不是K+1 ,图片有误
四次挥手的时候,客户端断开连接之后不会发送数据给服务器端,那为什么还可以回复ACK,是不是可以说客户端不能 " 主动 " 发送数据给服务器端?
- 四次挥手后甚至还要等2MSL(可以百度一下)才会真正断开连接,前面都没用断开,只是客户端表达了想断开的意愿,并且把这个意愿告知了服务器,但实质双方还是处于连接状态的,所以最后发送ACK自然没有问题。
- 这里不发送的是正常连接时传输的数据(非确认报文),而不是一切数据,所以客户端仍然能发送 ACK 确认报文。
# 11、TCP 通信并发(多进程)
要实现TCP通信服务器处理并发的任务,使用多线程或者多进程来解决。
思路:
1.一个父进程,多个子进程
2. 父进程负责等待并接受客户端的连接
3. 子进程:完成通信,接受一个客户端连接,就创建一个子进程用于通信。
2
3
4
5
6
# 11.1 初始版
//server_process.c
#include <stdio.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <signal.h>
#include <wait.h>
#include <errno.h>
int main()
{
// 创建socket
int lfd = socket(PF_INET, SOCK_STREAM, 0);
if (lfd == -1)
{
perror("socket");
exit(-1);
}
struct sockaddr_in saddr;
saddr.sin_family = AF_INET;
saddr.sin_port = htons(9999);
saddr.sin_addr.s_addr = INADDR_ANY;
// 绑定
int ret = bind(lfd, (struct sockaddr *)&saddr, sizeof(saddr));
if (ret == -1)
{
perror("bind");
exit(-1);
}
// 监听
ret = listen(lfd, 128);
if (ret == -1)
{
perror("listen");
exit(-1);
}
// 不断循环等待客户端连接
while (1)
{
struct sockaddr_in cliaddr;
int len = sizeof(cliaddr);
// 接受连接
int cfd = accept(lfd, (struct sockaddr *)&cliaddr, &len);
if (cfd == -1)
{
perror("accept");
exit(-1);
}
// 每一个连接进来,创建一个子进程跟客户端通信
pid_t pid = fork();
if (pid == 0)
{
// 子进程
// 获取客户端的信息
char cliIp[16];
inet_ntop(AF_INET, &cliaddr.sin_addr.s_addr, cliIp, sizeof(cliIp));
unsigned short cliPort = ntohs(cliaddr.sin_port);
printf("client ip is : %s, prot is %d\n", cliIp, cliPort);
// 接收客户端发来的数据
char recvBuf[1024];
while (1)
{
int len = read(cfd, &recvBuf, sizeof(recvBuf));
if (len == -1)
{
perror("read");
exit(-1);
}
else if (len > 0)
{
printf("recv client : %s\n", recvBuf);
}
else if (len == 0)
{
printf("client closed....\n");
break;
}
write(cfd, recvBuf, strlen(recvBuf) + 1);
}
close(cfd);
exit(0); // 退出当前子进程
}
}
close(lfd);
return 0;
}
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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
// client.c
//// TCP通信的客户端
#include <stdio.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
int main()
{
// 1. 创建套接字
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd == -1)
{
perror("socket");
exit(-1);
}
// 2.连接服务器端
struct sockaddr_in serveraddr;
serveraddr.sin_family = AF_INET;
inet_pton(AF_INET, "192.168.186.144", &serveraddr.sin_addr.s_addr);
serveraddr.sin_port = htons(9999);
int ret = connect(fd, (struct sockaddr *)&serveraddr, sizeof(serveraddr));
if (ret == -1)
{
perror("connect");
exit(-1);
}
// 3.通信
char recvBuf[1024];
int i = 0;
while (1)
{
sprintf(recvBuf, "data : %d\n", i++);
// 给服务器端发送数据
write(fd, recvBuf, strlen(recvBuf));
int len = read(fd, recvBuf, sizeof(recvBuf));
if (len == -1)
{
perror("read");
exit(-1);
}
else if (len > 0)
{
printf("recv servre : %s\n", recvBuf);
}
else if (len == 0)
{
// 表示服务器端断开连接
printf("server closed...");
break;
}
sleep(1);
}
// 关闭连接
close(fd);
return 0;
}
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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
这里之所以会出现上面这种情况是因为我们
client端
在写入的时候,写入的大小是用 strlen() 计算出来的,这样strlen在计数的时候是到结束符'\0'为止,但不包含结束符。 但服务器在读取时,read函数并不会在读取的内容的最后添加字符串的结束标志 '/0',因此读取出来的内容并不是一个合法的字符串,要自己添加一个字符串结束符才行。(老师应该是讲错了,他说读取不到换行符应该是字符串结束符才对)解决办法有二:
1、我们在定义数据缓冲区时,就直接给它赋初值
char recvBuf[1024]={0};
1但是这样还有有个问题就是如果我们每次写入的数据长度不同,那么读取到的数据可能会出错,比如说第一次写入了8个字符,第二次写入了5个,那我们输出出来,还是8个字符,因为上次写入的数据被保存在数据缓冲区里了,但是又没有被覆盖,要解决这个问题,我们又要在每次写入前清空缓冲区。
2、直接在写入的时候,写入数据加1
write(fd, recvBuf, strlen(recvBuf) + 1 );
1我们使用sprintf格式化字符串到数据缓冲区里是会有字符串结束符的,但是我们**
strlen
获取不到**,所以我们写入时多写入一位数据,就是字符串结束符了
# 11.2 第二版
- 虽然上面代码经过修改能实现我们想要的功能,但是不够完善,因为当我们子进程退出后,我们最好主动回收一下子进程的资源
//server_process.c
#include <stdio.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <signal.h>
#include <wait.h>
#include <errno.h>
void recyleChild(int arg)
{
while (1)
{
int ret = waitpid(-1, NULL, WNOHANG);
if (ret == -1)
{
// 所有的子进程都回收了
break;
}
else if (ret == 0)
{
// 还有子进程活着
break;
}
else if (ret > 0)
{
// 被回收了
printf("子进程 %d 被回收了\n", ret);
}
}
}
int main()
{
struct sigaction act;
act.sa_flags = 0;
sigemptyset(&act.sa_mask);
act.sa_handler = recyleChild;
// 注册信号捕捉
sigaction(SIGCHLD, &act, NULL);
// 创建socket
int lfd = socket(PF_INET, SOCK_STREAM, 0);
if (lfd == -1)
{
perror("socket");
exit(-1);
}
struct sockaddr_in saddr;
saddr.sin_family = AF_INET;
saddr.sin_port = htons(9999);
saddr.sin_addr.s_addr = INADDR_ANY;
// 绑定
int ret = bind(lfd, (struct sockaddr *)&saddr, sizeof(saddr));
if (ret == -1)
{
perror("bind");
exit(-1);
}
// 监听
ret = listen(lfd, 128);
if (ret == -1)
{
perror("listen");
exit(-1);
}
// 不断循环等待客户端连接
while (1)
{
struct sockaddr_in cliaddr;
int len = sizeof(cliaddr);
// 接受连接
int cfd = accept(lfd, (struct sockaddr *)&cliaddr, &len);
if (cfd == -1)
{
perror("accept");
exit(-1);
}
// 每一个连接进来,创建一个子进程跟客户端通信
pid_t pid = fork();
if (pid == 0)
{
// 子进程
// 获取客户端的信息
char cliIp[16];
inet_ntop(AF_INET, &cliaddr.sin_addr.s_addr, cliIp, sizeof(cliIp));
unsigned short cliPort = ntohs(cliaddr.sin_port);
printf("client ip is : %s, prot is %d\n", cliIp, cliPort);
// 接收客户端发来的数据
char recvBuf[1024];
while (1)
{
int len = read(cfd, &recvBuf, sizeof(recvBuf));
if (len == -1)
{
perror("read");
exit(-1);
}
else if (len > 0)
{
printf("recv client : %s\n", recvBuf);
}
else if (len == 0)
{
printf("client closed....\n");
break;
}
write(cfd, recvBuf, strlen(recvBuf) + 1);
}
close(cfd);
exit(0); // 退出当前子进程
}
}
close(lfd);
return 0;
}
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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
🟨🟨建立了两个通信,🟥关闭了先打开那个客户端,🟥出现报错(被系统调用打断),这时我们再想执行
./client
建立通信会报错🟦,同时如果我们在服务端按ctrl+c
关闭进程会失败,必须先把之前开启的客户端关闭才能ctrl+c
终止服务器端原因在于
accept()
,该系统调用被一个信号中断,该信号在连接建立之前被捕获,就会报EINTR
的错误。所以为了解决这个问题,我们应该对accpet的返回值进行判断:
int cfd = accept(lfd, (struct sockaddr *)&cliaddr, &len); if (cfd == -1) { if (errno == EINTR) { continue; //如果出错,跳出本次循环,重新调用accpet()等待建立连接 } perror("accept"); exit(-1); }
1
2
3
4
5
6
7
8
9
10
# 11.3 思考
- 为什么我们要使用信号捕捉来回收子进程资源,而不直接在循环里使用**
wait/waitpid
**函数来回收 ?- wait: 会阻塞,显然不合适
- waitpid :虽然可以甚至非阻塞,但是如果父进程中直接调用 wait/waitpid 有个问题,就是可能一直没有新客户端连接,父进程会一直阻塞在 accept() 处,无法执行到 wait 处。所以要设置信号捕捉。这样父进程阻塞在 accept() 处时会被打断去回收子进程。
# 11.4 其他问题
因为上面的代码都是直接贴的正确的,所以我们在运行过程中有些问题没看出来,这里主要是
client.c
里sleep(1)
使用位置不同会出现的问题?上面代码我们是把sleep(1)放在了每次循环的末尾,即读写操作之后,但是如果我们把休眠函数放到读写操作之间就会出现下面的错误
write(fd, recvBuf, strlen(recvBuf)); sleep(1); int len = read(fd, recvBuf, sizeof(recvBuf));
1
2
3
出现了
Connection reset by peer
的错误,(这里老师的解释我没太理解到和sleep位置的关系)😰😰😰
- 具体看课程列表_牛客网 (nowcoder.com) (opens new window)前几分钟讲的就是这个问题
- 评论底下很多人问出现上面错误和sleep的关系:(老师答复如下)
- 因为如果放到前面了,有一端关闭了,这个时候再发送数据或者接收数据就会报那个错误。你可以搜索一下那个错误具体的看看。你可以看下这个https://blog.csdn.net/xc_zhou/article/details/80950753
# 12、多线程实现并发服务器
- 客户端代码不变,还是上面
client.c
//server_thread.c
#include <stdio.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <signal.h>
#include <pthread.h>
struct sockInfo
{
int fd; // 通信的文件描述符
struct sockaddr_in addr;
pthread_t tid; // 线程号
};
// 固定了同时能创建的子线程的数量(即同时建立连接的客户端的数量)
struct sockInfo sockinfos[128];
void *working(void *arg)
{
// 子线程和客户端通信 cfd 客户端的信息 线程号
// 获取客户端的信息
// 接收参数是空指针,所以要强转
struct sockInfo *pinfo = (struct sockInfo *)arg;
char cliIp[16];
inet_ntop(AF_INET, &pinfo->addr.sin_addr.s_addr, cliIp, sizeof(cliIp));
unsigned short cliPort = ntohs(pinfo->addr.sin_port);
printf("client ip is : %s, prot is %d\n", cliIp, cliPort);
// 接收客户端发来的数据
char recvBuf[1024];
while (1)
{
int len = read(pinfo->fd, &recvBuf, sizeof(recvBuf));
if (len == -1)
{
perror("read");
exit(-1);
}
else if (len > 0)
{
printf("recv client : %s\n", recvBuf);
}
else if (len == 0)
{
printf("client closed....\n");
break;
}
write(pinfo->fd, recvBuf, strlen(recvBuf) + 1);
}
// 关闭文件描述符
close(pinfo->fd);
return NULL;
}
int main()
{
// 创建 socket
int lfd = socket(PF_INET, SOCK_STREAM, 0);
if (lfd == -1)
{
perror("socket");
exit(-1);
}
struct sockaddr_in saddr;
saddr.sin_family = AF_INET;
saddr.sin_port = htons(9999);
saddr.sin_addr.s_addr = INADDR_ANY;
// 绑定
int ret = bind(lfd, (struct sockaddr *)&saddr, sizeof(saddr));
if (ret == -1)
{
perror("bind");
exit(-1);
}
// 监听
ret = listen(lfd, 128);
if (ret == -1)
{
perror("listen");
exit(-1);
}
// 初始化数据
int max = sizeof(sockinfos) / sizeof(sockinfos[0]);
for (int i = 0; i < max; i++)
{
// 置字节字符串前n个字节为零且包括'\0'
bzero(&sockinfos[i], sizeof(sockinfos[i]));
// -1是无效的文件描述符,初始化为 -1就可以说明可用
sockinfos[i].fd = -1;
// 同上理
sockinfos[i].tid = -1;
}
// 循环等待客户端的链接,一旦一个客户端连接进来,就创建一个子进程进行通信
while (1)
{
struct sockaddr_in cliaddr;
int len = sizeof(cliaddr);
// 接受链接
int cfd = accept(lfd, (struct sockaddr *)&cliaddr, &len);
// 不能直接这样写,因为是局部变量,结束本次循环后就释放掉了,但是working是子线程的执行函数,局部变量被释放了,就传不过去了,所以不能直接创建 sockInfo结构体变量来使用
// struct sockInfo pinfo;
// 也不能这样写是因为,这样使用完子线程后还要去释放,这样在管理内存就比较麻烦,成本也比较高,还有就是如果我们的子线程(即客户端)非常多,比如十万个,那就要创建十万个,那资源的消耗就非常大
// struct sockInfo *pinfo = malloc()...
// 所以这里老师就是创建了一个全局变量数组,固定了同时能创建的子线程的数量(即同时建立连接的客户端的数量)
struct sockInfo *pinfo;
for (int i = 0; i < max; i++)
{
// 从这个数组中找到一个可以用的sockInfo元素
if (sockinfos[i].fd == -1)
{
pinfo = &sockinfos[i];
// 找到一个就退出,使用这个
break;
}
// 当同时使用了 128个后
if (i == max - 1)
{
// 等待一秒钟,然后重新进入循环看看哪个是可用的
sleep(1);
// 阻止跳出循环
i--;
}
}
pinfo->fd = cfd;
// 从源内存地址的起始位置开始拷贝若干个字节到目标内存地址中
memcpy(&pinfo->addr, &cliaddr, len);
// 创建子线程
// 因为pthread_create函数只能给子线程处理函数传入一个参数,但是我们需要的参数较多,所以我们需要构建一个结构体
pthread_create(&pinfo->tid, NULL, working, pinfo);
pthread_detach(pinfo->tid);
}
close(lfd);
return 0;
}
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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
- 这里有两个需要改进的点:
当
i= max -1
时,应该让i = -1
,而不是 i--- 原因是,如果只是
i--
,那么就是一直在等待i=127
那个文件描述符空闲下来,而其他文件描述符空闲下来也不会被利用到; - 还有为什么是
-1
,是因为执行完赋值,进入下次循环 i会自增 - 如果不写
i=-1
,写i =0
,就得把循环里得i++
,写成i = (i + 1) % max
- 原因是,如果只是
working函数close后,将占用的数组元素恢复初始化是否好些,不然客户端关闭了仍然占用着一个位置,即将使用过的数组元素中结构体中fd置为-1 ?
应该置为-1,不然用过的就无法重新再使用,原因:close()函数(关闭文件描述符,使其不再引用任何文件并且可以重用)(注意close(fd)后fd仍然为原值,需要手动置为-1)
改进方法1:在结构体中增加一个变量,去记录使用的结构体在数组中的位置,以便在线程退出时将给线程使用的结构体的fd置-1
//增加 struct sockInfo { int fd; // 通信的文件描述符 struct sockaddr_in addr; pthread_t tid; // 线程号 int sockinfos_id; // 线程号 }; ------------------------------------ //初始化 for(int i = 0; i < max; i++) { bzero(&sockinfos[i], sizeof(sockinfos[i])); sockinfos[i].fd = -1; sockinfos[i].tid = -1; sockinfos[i].sockinfos_id=-1; } -------------------------------------- // 选择可用的 sockInfo元素,并记录在数组中的位置 if(sockinfos[i].fd == -1) { pinfo = &sockinfos[i]; pinfo->sockinfos_id = i; //记录在数组中的位置,方便清除 break; } ------------------------------------------ // 子线程结束进行修改 else if(len == 0) { sockinfos[pinfo->sockinfos_id].fd=-1; printf("client closed....\n"); break; }
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
30
31
32
33
- 原文章
(31条消息) Linux高并发服务器开发笔记(牛客 多线程BUG修复)_牛客linux高并发服务器开发_H0U的博客-CSDN博客 (opens new window)
- 改进方法2:
- 在working函数的后面加上
pinfo->fd = -1
就行了,因为同一个程序内的线程共享文件描述符表
# 13、❗❗❗TCP 状态转换
# 13.1 状态转换
4次挥手,(上图举例)为什么被动关闭方发送
ACK
和FIN
不同时发送,而是分两次发送?- 补充:4次挥手不要求必须由哪一方发起
- 原因是,如果被动关闭方同时发送
ACK
和FIN
,那么在被动方发送ack同意主动关闭方关闭后,被动关闭方还想向主动发送方发送数据,那么,被动关闭方就不能给主动关闭方发送FIN
,所以被动关闭方不同时发送ACK
和FIN
3次握手,为什么一起发送?
- 补充:3次握手必须是由客户端发起
- 3次握手是要建立连接,竟然服务端同意了对方的连接,那就也要请求客户端同意跟服务端连接,所以3次握手的服务端的
SYN
和ACK
一起发送
2MSL(Maximum Segment Lifetime)
主动断开连接的一方, 最后进入一个 TIME_WAIT状态, 这个状态会持续: 2msl
msl: 官方建议: 2分钟, 实际是30s (在Ubuntu里是30s,不同系统不一样)
当 TCP 连接主动关闭方接收到被动关闭方发送的 FIN 和最终的 ACK 后,连接的主动关闭方 必须处于TIME_WAIT 状态并持续 2MSL 时间。
这样就能够让 TCP 连接的主动关闭方在它发送的 ACK 丢失的情况下重新发送最终的 ACK。
主动关闭方重新发送的最终 ACK 并不是因为被动关闭方重传了 ACK(它们并不消耗序列号, 被动关闭方也不会重传),而是因为被动关闭方重传了它的 FIN。事实上,被动关闭方总是 重传 FIN 直到它收到一个最终的 ACK。
(31条消息) TCP状态转换以及TIMEWAIT和FIN_WAIT_2状态_大草原的小灰灰的博客-CSDN博客 (opens new window)
# 13.2 半关闭
当 TCP 链接中 A 向 B 发送 FIN 请求关闭,另一端 B 回应 ACK 之后(A 端进入
FIN_WAIT_2
状态),并没有立即发送 FIN 给 A,A 方处于半连接状态(半开关/半关闭),此时 A 可以接收 B 发送的数据,但是 A 已经不能再向 B 发送数据。
- 从程序的角度,可以使用 API 来控制实现半连接状态:
#include <sys/socket.h>
int shutdown(int sockfd, int how);
- sockfd: 需要关闭的socket的描述符
- how: 允许为shutdown操作选择以下几种方式:
SHUT_RD(0):关闭sockfd上的读功能,此选项将不允许sockfd进行读操作。
该套接字不再接收数据,任何当前在套接字接受缓冲区的数据将被无声的丢弃掉。
SHUT_WR(1): 关闭sockfd的写功能,此选项将不允许sockfd进行写操作。进程不能在对此套接字发出写操作。
SHUT_RDWR(2):关闭sockfd的读写功能。相当于调用shutdown两次:首先是以SHUT_RD,然后以SHUT_WR。
2
3
4
5
6
7
8
9
- 使用
close
中止一个连接,但它只是减少描述符的引用计数,并不直接关闭连接,只有当描述符的引用 计数为 0 时才关闭连接。 shutdown
不考虑描述符的引用计数,直接关闭描述符。也可选择中止一个方向的连接,只中止读或只中止写。- 注意:
- 如果有多个进程共享一个套接字,close 每被调用一次,计数减 1 ,直到计数为 0 时,也就是所用 进程都调用了 close,套接字将被释放。
- 在多进程中如果一个进程调用了 shutdown(sfd, SHUT_RDWR) 后,其它的进程将无法进行通信。 但如果一个进程 close(sfd) 并不会影响到其它进程。
# 14、端口复用
# 14.1 常用查看网络信息的命令
netstat 参数:
- -a 所有的socket
- -p 显示正在使用socket的程序的名称
- -n 直接使用IP地址,而不通过域名服务器
netstat -apn | grep 9999 // netstat -anp 查看所有socket的网络信息 // grep 筛选其中出现 9999 的信息
1
2
3
# 14.2 需要使用端口复用的场景
//tcp_server.c
#include <stdio.h>
#include <ctype.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
int main(int argc, char *argv[])
{
// 创建socket
int lfd = socket(PF_INET, SOCK_STREAM, 0);
if (lfd == -1)
{
perror("socket");
return -1;
}
struct sockaddr_in saddr;
saddr.sin_family = AF_INET;
saddr.sin_addr.s_addr = INADDR_ANY;
saddr.sin_port = htons(9999);
// int optval = 1;
// setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval));
// int optval = 1;
// setsockopt(lfd, SOL_SOCKET, SO_REUSEPORT, &optval, sizeof(optval));
// 绑定
int ret = bind(lfd, (struct sockaddr *)&saddr, sizeof(saddr));
if (ret == -1)
{
perror("bind");
return -1;
}
// 监听
ret = listen(lfd, 8);
if (ret == -1)
{
perror("listen");
return -1;
}
// 接收客户端连接
struct sockaddr_in cliaddr;
socklen_t len = sizeof(cliaddr);
int cfd = accept(lfd, (struct sockaddr *)&cliaddr, &len);
if (cfd == -1)
{
perror("accpet");
return -1;
}
// 获取客户端信息
char cliIp[16];
inet_ntop(AF_INET, &cliaddr.sin_addr.s_addr, cliIp, sizeof(cliIp));
unsigned short cliPort = ntohs(cliaddr.sin_port);
// 输出客户端的信息
printf("client's ip is %s, and port is %d\n", cliIp, cliPort);
// 接收客户端发来的数据
char recvBuf[1024] = {0};
while (1)
{
int len = recv(cfd, recvBuf, sizeof(recvBuf), 0);
if (len == -1)
{
perror("recv");
return -1;
}
else if (len == 0)
{
printf("客户端已经断开连接...\n");
break;
}
else if (len > 0)
{
printf("read buf = %s\n", recvBuf);
}
// 小写转大写
for (int i = 0; i < len; ++i)
{
recvBuf[i] = toupper(recvBuf[i]);
}
printf("after buf = %s\n", recvBuf);
// 大写字符串发给客户端
ret = send(cfd, recvBuf, strlen(recvBuf) + 1, 0);
if (ret == -1)
{
perror("send");
return -1;
}
}
close(cfd);
close(lfd);
return 0;
}
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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
- (31条消息) 深入理解socket中的recv函数和send函数_socket recv_Gopher大威的博客-CSDN博客 (opens new window)
- 客户端使用
fgets()
主要是为了在我们服务器端主动退出时,阻塞客户端退出,便于观察其所处状态
//tcp_client.c
#include <stdio.h>
#include <arpa/inet.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
int main() {
// 创建socket
int fd = socket(PF_INET, SOCK_STREAM, 0);
if(fd == -1) {
perror("socket");
return -1;
}
struct sockaddr_in seraddr;
inet_pton(AF_INET, "127.0.0.1", &seraddr.sin_addr.s_addr);
seraddr.sin_family = AF_INET;
seraddr.sin_port = htons(9999);
// 连接服务器
int ret = connect(fd, (struct sockaddr *)&seraddr, sizeof(seraddr));
if(ret == -1){
perror("connect");
return -1;
}
while(1) {
char sendBuf[1024] = {0};
fgets(sendBuf, sizeof(sendBuf), stdin);
write(fd, sendBuf, strlen(sendBuf) + 1);
// 接收
int len = read(fd, sendBuf, sizeof(sendBuf));
if(len == -1) {
perror("read");
return -1;
}else if(len > 0) {
printf("read buf = %s\n", sendBuf);
} else {
printf("服务器已经断开连接...\n");
break;
}
}
close(fd);
return 0;
}
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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
- 上图1~4,每执行一个就对应下图查看一次
- 🟧,之所以出现 地址已经被占用 的错误就是因为我们是服务器端主动断开连接,服务器处于
FIN_WAIT_2
状态(如果服务器端断开后,客户端也断开就是处于TIME_WAIT
状态)而我们服务器端的IP和端口号是固定的,TIME_WAIT
这个状态会持续: 2msl,所以我们服务器端的ip和端口就被占用,所以就报错了 - 如果是先关闭的客户端,因为服务端我们没有使用
fget()
阻塞,那read返回值就等于0,服务器端也会退出,但是客户端的端口号是系统随机分配的,所以即使出现客户端(主动断开方)处于TIME_WAIT
状态,也不会出现上图报错
- 第1次查看只有一个tcp信息,是专门用来监听的socket,状态为
LISTEN
- 第2次增加了两个,这两个是客户端和服务器用来通信的socket,状态为
ESTABLISHED
(已经建立连接) - 第3次,是服务器端主动退出而客户端还没退出,所以,主动退出方状态为
FIN_WAIT2
, 但是因为其已经退出了所以🟩内没有显示正在使用socket的程序的名称 ,此时客户端处于 CLOSE_WAIT 状态 - 第4次,客户端也退出,主动退出方处于 TIME_WAIT 状态,没有真正完全退出
- 补充: 如果我们不退出客户端(不进行上上图**
4
**操作),同时隔一段时间再次查询会出现下图情况:
- 处于 FIN_WAIT2 状态的一端自动结束,老师的解释是进程已经结束,但是tcp的信息还没结束,可以理解为已经到了 TIME_WAIT 状态,所以过了一段时间就自动结束了
所以为了解决上面问题,就需要让端口复用
# 14.3 端口复用概述
端口复用最常用的用途是:
- 防止服务器重启时之前绑定的端口还未释放
- 程序突然退出而系统没有释放端口
#include <sys/types.h>
#include <sys/socket.h>
// 设置套接字的属性(不仅仅能设置端口复用)
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
参数:
- sockfd : 要操作的文件描述符
- level : 级别 - SOL_SOCKET (端口复用的级别)
- optname : 选项的名称
- SO_REUSEADDR 允许重用本地地址
- SO_REUSEPORT 允许重用本地端口
- optval : 端口复用的值(整形)
- 1 : 可以复用
- 0 : 不可以复用
- optlen : optval参数的大小
//注意:端口复用,设置的时机是在服务器绑定端口之前。
setsockopt();
bind();
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
- 具体使用就是14.2里
tcp_server.c
中bind前两种复用的写法任选一种都可以