在这个信息爆炸的时代,有关socket编程的文章多如牛毛,而且还在不断产出,隔三差五就能在各种微信公众号之类的地方看到。我也打算写一个有关 socket 编程的系列文章,不是因为我膨胀到觉得比别人写得好,而是为了加深对这部分知识的印象,查漏补缺(下笔之前总会多查些资料,以免写出来贻笑大方是不是),同时也是为了练习书面表达能力。鉴于本人有限的知识水平和写作水平,错误也在所难免,还望指正。
这个系列的文章主要围绕 Unix/Linux 下的 socket 网络编程,不涉及 Windows (我也不懂)。在形式上,会围绕如何实现一个具体的需求(demo),分析其中需要用到哪些知识点并给出一个可运行的代码实现。虽然 socket 网络编程并不限定于哪种具体的编程语言,可谁让操作系统内核都是C语言实现的呢,所以选用C语言来描述绝对是正确的做法。但是大概率上也不会涉及内核的具体代码实现(因为我不懂),只会涉及到一些基本的函数和系统调用。
需求:一个服务器服务一个客户端
具体归纳为以下几条:
- 启动一个进程并在某个端口上监听TCP连接请求。
- 读取客户端请求数据,并将读取到的数据转换成大写后返回给客户端。
- 服务端仅能同时服务一个客户端,期间若有其它客户端尝试连接,则阻塞。
socket核心API介绍
1、socket
由于类 Unix 系统中一切皆文件的宗旨,socket 编程需要基于一个文件描述符,即 socket 文件描述符。socket(2) 系统调用就是用来创建 socket 文件描述符。
函数原型如下:
1
2
3
|
#include <sys/types.h>
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
|
参数列表
domain |
描述 |
AF_UNIX, AF_LOCAL |
Unix域协议 |
AF_INET |
IPv4协议 |
AF_INET6 |
IPv6协议 |
type |
描述 |
SOCK_STREAM |
字节流套接字 |
SOCK_DGRAM |
数据包套接字 |
SOCK_SEQPACKET |
有序分组套接字 |
SOCK_RAW |
原始套接字 |
protocol
: 一般设置为0
,表示系统会根据 domain 和 type 的值自动选择一个合适的值。
返回值
发生错误返回-1,否则返回socket文件描述符。
2、bind
通过 socket 系统调用创建的文件描述符并不能直接使用,TCP/UDP协议中所涉及的协议、IP、端口等基本要素并未体现,而 bind(2) 系统调用就是将这些要素与文件描述符关联起来。
函数原型如下:
1
2
|
#include <sys/socket.h>
int bind(int socket, const struct sockaddr *address, socklen_t address_len);
|
参数列表
返回值
错误
3、listen
使用 socket 系统调用创建一个套接字时,它被假设是一个主动套接字(客户端套接字),而调用 listen(2) 系统调用就是将这个主动套接字转换成被动套接字,指示内核应接受指向该套接字的连接请求。
listen 还有项重要使命,就是创建SYN QUEUE和ACCEPT QUEUE,中文译为未完成连接队列(半连接队列)和已完成连接队列(全连接队列)。内核为每一个监听套接字都维护着这两个队列,未完成三次握手的连接暂时存放在未完成队列,已完成三次握手并且服务端还未调用 accept 系统调用处理的连接均存放在已完成连接队列。
函数原型如下:
1
2
|
#include <sys/socket.h>
int listen(int socket, int backlog);
|
参数列表
-
socket
: socket 监听文件描述符。
-
backlog
: 设置未完成连接队列和已完成连接队列各自的队列长度(注意:不同的系统对该值的解释会存在差异)。
Linux系统下,SYN QUEUE 队列长度阈值存放在/proc/sys/net/ipv4/tcp_max_syn_backlog
文件中,ACCEPT QUEUE 队列长度阈值存放在/proc/sys/net/core/somaxconn
文件中。两个队列长度的计算公式如下:
- SYN QUEUE 队列的长度:min(backlog, somaxconn, tcp_max_syn_backlog) + 1 再上取整到 2 的幂次但(最小不能小于16)
- ACCEPT QUEUE 队列长度:min(backlog, somaxconn)
对于存在高并发场景的服务端程序,应该将 backlog 适当调大(Nginx和Redis的默认backlog值为511)。
返回值
4、accept
accept(2) 系统调用将尝试从已完成连接队列的队头中取出一个连接进行服务,因此产生的队列空缺将从未完成连接队列中取出一个进行补充。若此时已完成连接队列为空,且 socket 文件描述符为默认的阻塞模式,那么进程将被挂起。
函数原型如下:
1
2
3
|
#include <sys/types.h>
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
|
参数列表
返回值
出错返回-1,否则返回已连接套接字文件描述符。
错误
EAGAIN/EWOULDBLOCK
: 若已连接队列为空且监听文件描述符被设置为非阻塞模式,那么 errno 将被设置为EAGAIN
或者EWOULDBLOCK
。
EINTR
: 若被信号(如SIGCHLD
)中断,那么 errno 将被设置为EINTR
。
5、connect
创建主动套接字的一方(客户端)调用 connect(2) 系统调用,可建立与被动套接字的一方(服务端)的连接。
不同于被动套接字方在调用 listen 之前必须调用 bind 绑定文件描述符与协议地址,主动套接字方在发起连接前,一般都不会调用 bind 绑定文件描述符和协议地址。因为在未绑定情况下,内核会确定源IP地址,并选择一个临时的未被占用的端口作为源端口。如果进行了绑定,所指定的端口又已经被占用,那么 connect 将返回 EADDRINUSE 错误。
函数原型如下:
1
2
3
|
#include <sys/types.h>
#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
|
参数列表
sockfd
: 连接文件描述符。
addr
: 特定协议的地址结构体指针。同 bind 系统调用参数。
addrlen
: 协议地址结构体长度。同 bind 系统调用参数。
返回值
成功返回0,失败返回-1并设置 errno 值。
错误
EADDRINUSE
: 若客户端的连接文件描述符被设置了地址重用选项(SO_REUSEADDR),又调用了 bind 绑定了固定端口,那么重复连接都将返回此错误。一般而言,客户端端口并不会人为指定,而是由内核选择一个未被占用的端口进行连接。
ECONNREFUSED
: 若客户端的SYN的响应是RST,则表明指定的地址(IP+端口)上并没有进程在等待连接。
ETIMEDOUT
: 若客户端在重试了多次后依然没有收到SYN的响应,那么返回该错误。
6、close
close(2) 一个TCP套接字的默认行为是把该套接字标记为关闭,此后不能再对该文件描述符进行读写操作。TCP协议将尝试发送已排队等待发送到对端的任何数据,发送完毕后发生的是正常的TCP连接终止序列。
close 会对文件描述符进行引用计数减一操作,引用计数不为零,则文件描述符不会真正关闭。若父进程在使用 fork(2) 系统调用前打开了某个文件,那么该文件描述符的引用计数就是2,子进程和父进程在退出前都必须各自调用一次 close 以真正关闭该文件。
函数原型如下:
1
2
|
#include <unistd.h>
int close(int fd);
|
参数列表
返回值
成功返回0,失败返回-1并设置 errno 值。
错误
EINTR
: 若被信号(如SIGCHLD
)中断,那么 errno 将被设置为EINTR
。
代码实现
服务端
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
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
|
// server.c
#include <arpa/inet.h>
#include <ctype.h>
#include <fcntl.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>
int main(int argc, char *argv[]) {
if (argc < 2) {
fprintf(stderr, "Usage: %s port\n", argv[0]);
return EXIT_FAILURE;
}
int port = atoi(argv[1]);
// 1、创建监听用的文件描述符
int lfd = socket(AF_INET, SOCK_STREAM, 0);
if (lfd == -1) {
perror("socket error");
return EXIT_FAILURE;
}
// 2、将监听文件描述符和IP端口信息绑定
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY); // 表示任意可用IP
addr.sin_port = htons(port); // 转换成网络字节序(大端字节序)
int ret = bind(lfd, (struct sockaddr *)&addr, sizeof(addr));
if (ret == -1) {
perror("bind error");
return EXIT_FAILURE;
}
// 3、监听文件描述符
if ((ret = listen(lfd, 128)) == -1) {
perror("listen error");
return EXIT_FAILURE;
}
printf("[%d]The server is running at %s:%d\n", getpid(), inet_ntoa(addr.sin_addr), port);
// 4、接受一个socket连接(从已连接队列中获取一个连接进行服务),并返回连接文件描述符。
struct sockaddr_in clientAddr; // 输入参数
socklen_t clientAddrLen = sizeof(clientAddr); // 同时作为输入和输出参数
int cfd = accept(lfd, (struct sockaddr *)&clientAddr, &clientAddrLen);
if (cfd == -1) {
perror("accept error");
return EXIT_FAILURE;
}
char clientIP[16];
memset(clientIP, 0x00, sizeof(clientIP));
inet_ntop(AF_INET, &clientAddr.sin_addr, clientIP, sizeof(clientIP)); // 将网络字节序的整数IP转换成主机字节序的点分十进制字符串
int clientPort = ntohs(clientAddr.sin_port); // 将网络字节序转换成主机字节序
printf("Accept client: %s:%d\n", clientIP, clientPort);
// 5、读写连接
char buf[BUFSIZ];
ssize_t size;
for (;;) {
// 初始化buffer
memset(buf, 0x00, sizeof(buf));
// 读取客户端信息
size = read(cfd, buf, sizeof(buf));
if (size == 0) { // zero indicates end of file
printf("The client is closed\n");
break;
}
if (size == -1) {
perror("read error");
continue;
}
printf("read: %s\n", buf);
for (int i = 0; i < strlen(buf); i++) {
buf[i] = toupper(buf[i]);
}
// 发送信息给客户端
size = write(cfd, buf, strlen(buf));
if (size == -1) {
perror("write error");
continue;
}
printf("write: %s\n", buf);
}
close(lfd);
close(cfd);
printf("The server is shut down\n");
return EXIT_SUCCESS;
}
|
客户端
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
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
|
// client.c
#include <arpa/inet.h>
#include <ctype.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>
int main(int argc, char *argv[]) {
if (argc < 3) {
fprintf(stderr, "Usage: %s host port\n", argv[0]);
return EXIT_FAILURE;
}
char *host = argv[1];
int port = atoi(argv[2]);
int cfd = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP);
if (cfd == -1) {
perror("socket error");
return EXIT_FAILURE;
}
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = inet_addr(host); // 方式一
// inet_pton(AF_INET, host, &addr.sin_addr.s_addr); // 方式二
addr.sin_port = htons(port);
int ret = connect(cfd, (struct sockaddr *)&addr, sizeof(addr));
if (ret == -1) {
perror("connect error");
return EXIT_FAILURE;
}
printf("The remote server is connected -> %s:%d\n", host, port);
char buf[BUFSIZ];
ssize_t size;
for (int i = 0; i < 10; i++) {
printf("Please enter content:\n");
memset(buf, 0x00, sizeof(buf));
if ((size = read(STDIN_FILENO, buf, sizeof(buf))) <= 0) {
continue;
}
if ((size = write(cfd, buf, strlen(buf))) == -1) { // 往内核的发送缓冲区中写入数据(由内核决定何时发送数据)
perror("write error");
break;
}
memset(buf, 0x00, sizeof(buf));
size = read(cfd, buf, sizeof(buf));
if (size == -1) {
perror("read error");
break;
}
if (size == 0) { // zero indicates end of file
printf("The server is shut down\n");
break;
}
printf("Reply: %s\n", buf);
}
close(cfd);
printf("The client is closed\n");
return EXIT_SUCCESS;
}
|
编译
1
2
|
$ gcc server.c -o server
$ gcc client.c -o client
|
运行
1
2
3
4
5
6
7
8
9
|
$ ./server 8989
[3243777]The server is running at 0.0.0.0:8989
Accept client: 127.0.0.1:56588
read: hello world
write: HELLO WORLD
The client is closed
The server is shut down
|
1
2
3
4
5
6
7
8
|
$ ./client 127.0.0.1 8989
The remote server is connected -> 127.0.0.1:8989
Please enter content:
hello world
Reply: HELLO WORLD
Please enter content:
^C
|
参考