TCP套接字编程入门

概述

套接字(socket)是一种通信机制,凭借这种机制,客户与服务器的通信既可以在本地单机上进行,也可以跨网络进行。

在这里插入图片描述
图:基本的TCP客户/服务器应用程序的套接字函数

图中展示了一对TCP客户与服务器进程之间进行通信时调用套接字函数的交互情况。服务器首先启动,然后监听客户的连接。稍后客户试图连接服务器,客户连接成功后,客户给服务器发送请求,服务器处理请求,并且返回给客户一个响应。这个过程一直持续下去,直到客户关闭客户端的连接,接着服务器也关闭相应的服务器端的连接,接着服务器继续等待新的客户连接。

如何编写TCP套接字程序

编写 TCP套接字程序,涉及具体的步骤:

  • 创建套接字
  • 命名套接字
  • 创建套接字队列
  • 服务器接受客户连接
  • 客户请求连接服务器
  • 发送和接收消息
  • 关闭套接字

创建套接字

可以使用系统调用socket来创建一个套接字并返回该套接字的文件描述符。

1
2
#include <sys/socket.h>
int socket(int domain, int type, int protocol);

创建的套接字是一条通信线路的一个端点。

domain
domain参数指定哪种协议族,常见的协议族包括 AF_UNIX 和 AF_INET。AF_UNIX 用于通过文件系统实现的本地套接字(类似于pipe管道),AF_INET 用于网络套接字。

type
type参数指定这个套接字的通信类型,取值包括 SOCK_STREAM 和 SOCK_DGRAM。
SOCK_STREAM 即流套接字,基于 TCP,提供可靠,有序的服务。
SOCK_DGRAM 即数据报套接字,基于 UDP,提供不可靠,无序的服务。SOCK_STREAM 类型的套接字为本文讲述的重点。

protocol
protocol允许为套接字指定一种协议。对于 AF_UNIX 和 AF_INET,我们使用默认值即可。

以下代码创建一个 TCP套接字,domain使用 AF_INET,type 使用 SOCK_STREAM,protocol 协议使用默认的 0 值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <sys/socket.h>
#include <stdlib.h>
#include <stdio.h>

int main(int argc, char **argv)
{
// 创建TCP套接字
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) {
perror("cannot create socket");
return 0;
}
printf("created socket, fd: %d\n", fd);
exit(0);
}

命名套接字

要想让创建的套接字可以被其他进程使用,那必须给该套接字命名。对套接字命名的意思是指将该套接字关联一个IP地址和端口号,可以使用系统调用bind来实现命名套接字。

1
2
#include <sys/socket.h>
int bind(int socket, const struct sockaddr *address, size_t address_len);

bind系统调用把参数address中的地址分配给与文件描述符socket关联的套接字,地址结构的长度由参数address_len传递。

每种套接字域都有其自己的格式,对于 AF_INET 域来说,套接字地址由结构 socket_in来指定,它至少包含以下几个成员:

1
2
3
4
5
struct sockaddr_in {
short int sin_family; // AF_INET
unsigned short int sin_port; // 端口号
struct in_addr sin_addr; // IP地址
};

成员sin_port表示套接字的端口号。对于客户套接字,我们一般不需要指定套接字的端口号,而对于服务器套接字,我们需要指定套接字的端口号以便让客户正确向服务器发送数据。如果不需要指定端口号,可以将sin_port的值赋为0。

成员sin_addr表示套接字的地址,即机器的IP地址。如果我们没特别为套接字绑定IP地址,操作系统会为我们选择一个机器IP地址,这时sin_addr使用地址0.0.0.0,使用INADDR_ANY来表示这个地址常量。

对一个套接字进行命名的示例代码如下,该代码创建一个TCP套接字,并监听机器的6240端口。

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
#include <sys/socket.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <netinet/in.h>

int main(int argc, char **argv)
{
// 创建套接字
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) {
perror("cannot create socket");
return 0;
}
printf("created socket, fd: %d\n", fd);

// 命名套接字
struct sockaddr_in myaddr;
memset((void *)&myaddr, 0, sizeof(myaddr));
myaddr.sin_family = AF_INET;
myaddr.sin_addr.s_addr = htonl(INADDR_ANY);
myaddr.sin_port = htons(6240);
if (bind(fd, (struct sockaddr *)&myaddr, sizeof(myaddr)) < 0) {
perror("bind failed");
return 0;
}
printf("bind complete, port number: %d\n", ntohs(myaddr.sin_port));

exit(0);
}

输出:

created socket, fd: 3
bind complete, port number: 6240

htonl(host to network, long,长整数从主机字节序到网络字节序的转换)htons(host to network, short,短整数从主机字节序到网络字节序的转换)这两个函数用于字机字节序和网络字节序的转换。

创建套接字队列

为了能够在套接字上接受进入的连接,服务器程序必须创建一个队列来保存未处理的请求。使用listen系统调用来完成这一工作。

1
2
#include <sys/socket.h>
int listen(int socket, int backlog);

Linux系统可能会对队列中可以容纳的未处理连接的最大数量做限制。为了遵守这个最大值限制,listen函数将队列长度设置为backlog参数的值。在套接字队列中,等待处理的进入连接的个数最多不能超过这个数字,再往后的连接将被拒绝,导致客户的连接请求失败。
listen函数提供的这种机制允许当服务器繁忙时将后续的客户连接放入队列等待处理。backlog参数常用的值是5。

创建套接字队列的代码如下所示:

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
#include <sys/socket.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <netinet/in.h>

int main(int argc, char **argv)
{
// 创建套接字
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) {
perror("cannot create socket");
return 0;
}
printf("created socket, fd: %d\n", fd);

// 命名套接字
struct sockaddr_in myaddr;
memset((void *)&myaddr, 0, sizeof(myaddr));
myaddr.sin_family = AF_INET;
myaddr.sin_addr.s_addr = htonl(INADDR_ANY);
myaddr.sin_port = htons(6240);
if (bind(fd, (struct sockaddr *)&myaddr, sizeof(myaddr)) < 0) {
perror("bind failed");
return 0;
}
printf("bind complete, port number: %d\n", ntohs(myaddr.sin_port));

// 创建套接字队列
if (listen(fd, 5) < 0) {
perror("listen failed");
exit(1);
}

exit(0);
}

服务器接受客户连接

服务器创建并命名了套接字之后,就可以通过accept系统调用来等待客户建立对该套接字的连接。

1
2
#include <sys/socket.h>
int accept(int socket, struct sockaddr *address, socklen_t *address_len);

accept只有在有客户尝试连接到由socket参数指定的套接字时才返回。即如果套接字队列中没有未处理的连接,accept将阻塞直到有客户建立连接为止。
accept函数将创建一个新套接字来与该客户进行通信,并且返回新套接字的描述符。值得指出的是,新套接字的类型和服务器监听套接字类型是一样的,都是SOCK_STREAM套接字。这个新套接字也称之为已连接套接字(connected socket),原来的用作监听客户连接请求的套接字称之为监听套接字(listen socket)。关于监听套接字和已连接套接字的介绍可以参考《UNIX网络编程,卷1:套接字和联网API(第三版)》第二章对于TCP端口的介绍。 理解的关键是将TCP连接看作是一个4元组:{(IP1: port1),(IP2: port2)}。

accept接受客户连接的示意代码如下所示:

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
#include <sys/socket.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>

int main(int argc, char **argv)
{
// 创建套接字
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd < 0) {
perror("cannot create socket");
return 0;
}
printf("created socket, server_fd: %d\n", server_fd);

// 命名套接字
struct sockaddr_in server_addr;
memset((void *)&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_port = htons(6240);
if (bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
perror("bind failed");
return 0;
}
printf("bind complete, port number: %d\n", ntohs(server_addr.sin_port));

// 创建套接字队列
if (listen(server_fd, 5) < 0) {
perror("listen failed");
exit(1);
}
printf("socket listen, server_fd: %d\n", server_fd);

// 服务器等待客户连接
const int MAXBUF = 256;
char buffer[MAXBUF];
struct sockaddr_in client_addr;
int client_addr_len = sizeof(client_addr);
int client_fd;
while (1) {
client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &client_addr_len);
printf("accept client, client fd: %d, ip: %s, port: %d\n", client_fd,
inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));

// 接收数据
int nbytes = read(client_fd, buffer, MAXBUF);
printf("read from client, bytes: %d, data: %s\n", nbytes, buffer);

// 关闭套接字
close(client_fd);
}
}

为了节省篇幅,在上面代码中,我们添加了服务器接收客户数据以及关闭套接字的代码,关于这两部分处理的解释会在下文介绍。

客户请求连接服务器

客户套接字与服务器套接字之间建立连接,这一过程是调用connect系统调用来完成。

1
2
#include <sys/socket.h>
int connect(int socket, const struct sockaddr *address, site_t address len);

参数socket指定的客户套接字将连接到参数address指定的服务器套接字,address指向的结构的长度由参数address_len指定。
调用connect后,会触发TCP三次握手过程,接着会建立起客户与服务器之间的连接。如果连接不能立刻建立,connect调用将阻塞一段不确定的时间。一旦超过这个时间,连接将被放弃,connect调用失败。

客户使用connect函数来连接服务器的代码示例如下所示:

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
#include <sys/socket.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <netinet/in.h>
#include <arpa/inet.h>

int main(int argc, char **argv) {
// 创建套接字
int client_fd = socket(AF_INET, SOCK_STREAM, 0);
if (client_fd < 0) {
perror("cannot create socket");
return 0;
}
printf("created socket, client_fd: %d\n", client_fd);

// 命名套接字,客户端由内核选择IP地址和端口
struct sockaddr_in client_addr;
memset((char *)&client_addr, 0, sizeof(client_addr));
client_addr.sin_family = AF_INET;
client_addr.sin_addr.s_addr = htonl(INADDR_ANY);
client_addr.sin_port = htons(0);
if (bind(client_fd, (struct sockaddr *)&client_addr,
sizeof(client_addr)) < 0) {
perror("bind failed");
return 0;
}

// 构造服务器的地址
const char *server = "127.0.0.1"; // 服务器IP
struct sockaddr_in server_addr;
memset((char*)&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
inet_aton(server, &server_addr.sin_addr);
server_addr.sin_port = htons(6240);

// 请求连接服务器
int result = connect(client_fd, (struct sockaddr *)&server_addr,
sizeof(server_addr));
if (result < 0) {
perror("connect failed");
}

// 发送数据
const int MAXBUF = 256;
char buffer[MAXBUF] = "hello tcp";
int nbytes = write(client_fd, buffer, 10);
printf("write to server, bytes: %d, data: %s\n", nbytes, buffer);

// 关闭套接字
close(client_fd);
}

在上面代码中,为方便起见,我们使用了inet_aton来构造服务器的套接字地址,inet_aton的作用是将一个IP地址字符串转换为一个32位的网络字节序的IP地址。

利用套接字收发数据

客户与服务器建立连接后,就可以利用readwrite系统调用来传输数据了。

例如,在上面的代码中,包含了客户向服务器发送消息的方法:

1
2
3
const int MAXBUF = 256;
char buffer[MAXBUF] = "hello tcp";
int nbytes = write(client_fd, buffer, 10);

如果服务器要读取客户发送的数据,则可以使用类似下面的代码:

1
2
3
const int MAXBUF = 256;
char buffer[MAXBUF];
int nbytes = read(client_fd, buffer, MAXBUF);

关闭连接

我们可以通过调用close函数来终止服务器与客户的套接字连接,就如同对底层文件描述符进行关闭一样。对套接字的使用结束后,我们应该正确关闭套接字以避免套接字资源的泄漏。

1
2
#include <unistd.h>
int close(int socket);

上面的程序中,我们都看到了close函数的调用。

对于多进程并发服务器的情形,使用 close只是导致套接字描述符的引用计数值减1。如果引用计数值仍大于0,这个close调用并不引发TCP终止连接的四次挥手过程。对于父进程与子进程共享已连接套接字(connected socket)的并发服务器来说,这正是所期望的。
如果我们确实想在某个TCP连接上发送一个FIN,那么可以改用shutdown函数以代替close。关于shutdownclose的区别,我们再会在另一篇文章描述。

参考资料

  1. Linux程序设计(第4版),Neil Matthew等著,人民邮电出版社,2010年
  2. UNIX 网络编程卷1:套接字联网API(第三版), W.Richard Stevens 等著
  3. https://www.cs.rutgers.edu/~pxk/417/notes/sockets/index.html