一、TCP socket API 详解
1、插件套接字 socket
在通信之前要先把网卡文件打开。
- socket() 打开一个网络通讯端口,如果成功的话,就像 open() 一样返回一个文件描述符。
- 应用程序可以像读写文件一样用 read/write 在网络上收发数据。
- 如果 socket()调用出错则返回 -1。
- 对于 IPv4,family参数指定为 AF_INET。
- 对于 TCP 协议,type 参数指定为 SOCK_STREAM,表示面向流的传输协议。
- protocol 参数的介绍忽略,指定为 0 即可。
成功则返回打开的文件描述符(指向网卡文件),失败返回-1。
这个函数的作用是打开一个文件,把文件和网卡关联起来。
- domain:一个域,标识了这个套接字的通信类型(网络或者本地)。
(只需要关注上面两个类,第一个 AF_UNIX 表示本地通信,而 AF_INET 表示网络通信。
- type:套接字提供服务的类型。
-
protocol:想使用的协议,默认为 0 即可,因为前面的两个参数决定了,就已经决定了是 TCP 还是 UDP 协议了。
从这里我们就可以联想到系统中的文件操作,未来各种操作都要通过这个文件描述符,所以在服务端类中还需要一个成员变量表示文件描述符。
2、绑定 bind
- 服务器程序所监听的网络地址和端口号通常是固定不变的,客户端程序得知服务器程序的地址和端口号后就可以向服务器发起连接,服务器需要调用 bind 绑定一个固定的网络地址和端口号。
- bind() 的作用是将参数 sockfd 和 myaddr 绑定在一起,使 sockfd 这个用于网络通讯的文件描述符监听 myaddr 所描述的地址和端口号。
- 前面讲过,struct sockaddr* 是一个通用指针类型,myaddr 参数实际上可以接受多种协议的 sockaddr 结构体,而它们的长度各不相同,所以需要第三个参数 addrlen 指定结构体的长度。
bind() 成功返回 0,失败返回 -1。
- socket:创建套接字的返回值。
- address:通用结构体(前面有详细介绍)。
- address_len:传入结构体的长度。
所以我们需要先定义一个address_in 结构体填充数据,再传递进去。
3、设置监听状态 listen
因为 TCP 是面向连接的,当我们正式通信的时候,需要先建立连接,那么 TCP 跟 UDP 的不同在这里就体现了出来。要把 socket 套接字的状态设置为 listen 状态,只有这样才能一直获取新链接,接收新的链接请求。
举例帮助理解:我们买东西如果出现了问题会去找客服,如果客服不在,那么就无法回复我们,所以就规定了客服在工作的时候必须要时刻接收回复消息,那么这个客服所处的状态就叫做监听状态。
关于第二个参数:backlog,后边讲 TCP 协议参数时会再详细介绍,目前先直接用。( 一般不能太大,也不能太小)
listen() 成功返回 0,失败返回 -1。
- listen() 声明 sockfd 处于监听状态,并且最多允许有 backlog 个客户端处于连接等待状态,如果接收到更多的连接请求就忽略,这里设置不会太大(一般是 5)。
创建套接字成功,套接字对应的文件描述符值是 3,为什么是 3 呢?
因为当前对应的文件描述符返回的套接字本身就是一个文件描述符,0、1、2 被占用,再创建一个文件,对应的就是 3。
4、获取新链接 accept
前面初始化完成,现在就是要开始运行服务端。TCP 不能直接发送数据,因为它是面向链接的,所以必须要先建立链接。
成功返回一个文件描述符,失败返回 -1。
- sockfd:文件描述符,找到套接字。
- addr:输入输出型参数,是一个结构体,用来获取客户端的信息。
- addrlen:输入输出型参数,客户端传过来的结构体大小。
- 三次握手完成后,服务器调用 accept() 接受连接。
- 如果服务器调用 accept() 时还没有客户端的连接请求,就阻塞等待直到有客户端连接上来。
- addr 是一个传出参数,accept() 返回时传出客户端的地址和端口号。
- 如果给 addr 参数传 NULL,表示不关心客户端的地址。
- addrlen 参数是一个传入传出参数 (value-result argument),传入的是调用者提供的,缓冲区 addr 的长度以避免缓冲区溢出问题,传出的是客户端地址结构体的实际长度(有可能没有占满调用者提供的缓冲区)。
我们的服务器程序结构是这样的:
sockfd 本来就是一个文件描述符,那么这个返回的文件描述符是什么呢?
举例帮助理解:我们去吃饭时,会发现一些店铺的门口有工作人员来招揽顾客,他将我们领进门店之后,他会站在门口继续招揽顾客,而我们会由里面的服务员来招待我们,给我们提供服务。
这里揽客的工作人员指的就是 sockfd,而店里面的服务员就是返回值的文件描述符。也就是说,sockfd 的作用就是把链接从底层获取上来,而返回值的作用就是跟客户端通信。
那么我们就知道了,成员变量中的 _sock 并不是通信用的套接字,而是获取链接的套接字。为了方便观察,我们可以把前面所有的 _sock 换成 _listensock。
客服端的整体代码如下:
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <cstring>
#include <cerrno>
#include <string>
#include "log.hpp"
#include <unistd.h>
Log lg;
class TcpServer
{
public:
// 下面这个构造函数时全缺省的,就不需要这个默认构造了
// TcpServer()
// {
// }
// 第一个参数时listensock,但是这个时在init的时候socket接口创建的套接字,你如果传参的话,第一个传递的应该时监听套接字,而不是一个端口号,
// 你在调用的时候,传递的时一个端口号,所以这里时有问题的,
// TcpServer(int sock = 0, uint16_t port = 8080, std::string ip = "0.0.0.0")
// : listensock_(sock), port_(port), ip_(ip)
// {
// }
TcpServer(uint16_t port = 8080, std::string ip = "0.0.0.0")
: listensock_(-1), port_(port), ip_(ip)
{
}
void Init()
{
listensock_ = socket(AF_INET, SOCK_STREAM, 0);
if (listensock_ < 0)
{
lg(Fatal, "%d:%s", errno, strerror(errno));
exit(2);
}
lg(Info, "Create socket success,sockfd:%d", listensock_);
struct sockaddr_in local;
bzero(&local, 0);
local.sin_family = AF_INET;
local.sin_port = htons(port_);
local.sin_addr.s_addr = inet_addr(ip_.c_str());
socklen_t len = sizeof(local);
if (bind(listensock_, (const sockaddr *)&local, len) < 0)
{
lg(Fatal, "bind error, errno: %d, err string: %s", errno, strerror(errno));
exit(3);
}
lg(Info, "bind success!");
if (listen(listensock_, 5) < 0)
{
lg(Fatal, "listen error, errno: %d, err string: %s", errno, strerror(errno));
exit(4);
}
}
void start()
{
lg(Info, "tcpServer is running....");
// 1. 获取新连接
char buffer[1024];
struct sockaddr_in client;
memset(&client, 0, sizeof(client));
socklen_t len = sizeof(client);
while (true)
{
// 这里是由问题的,因为你获取一个连接之后,应该是多次进行通信,而不是通信一次之后,在获取连接,因为上一个连接还没有处理完呢
int sockfd = accept(listensock_, (struct sockaddr *)&client, &len);
if (sockfd < 0)
{
lg(Warning, "accept error, errno: %d, errstring: %s", errno, strerror(errno)); //?
continue;
}
uint16_t clientport = ntohs(client.sin_port);
string clientip = inet_ntoa(client.sin_addr);
// 连接成功后,为客户端提供服务
while (1)
{
// 连接成功之后,多次处理这个连接的请求
ssize_t size = read(sockfd, buffer, sizeof(buffer) - 1);
if (size > 0)
{
buffer[size] = 0;
cout << '[' << clientip << ':' << clientport << "]# " << buffer << endl;
write(sockfd, buffer, size);
}
else if (size == 0)
{
// 如果读取到0的时候,就是对方断开了,
// 需要做的事情就是关闭套接字,然后跳出循环,获取下一个连接
close(sockfd);
break;
}
else
{
// 如果是小于0的话,那么就说明read这个错误失败了,也需要关闭连接
close(sockfd);
break;
}
}
}
// while (true)
// {
// // 这里是由问题的,因为你获取一个连接之后,应该是多次进行通信,而不是通信一次之后,在获取连接,因为上一个连接还没有处理完呢
// int sockfd = accept(listensock_, (struct sockaddr *)&client, &len);
// if (sockfd < 0)
// {
// lg(Warning, "accept error, errno: %d, errstring: %s", errno, strerror(errno)); //?
// continue;
// }
// uint16_t clientport = ntohs(client.sin_port);
// string clientip = inet_ntoa(client.sin_addr);
// // 连接成功后,为客户端提供服务
// ssize_t size = read(sockfd, buffer, sizeof(buffer) - 1);
// if (size > 0)
// {
// buffer[size] = 0;
// cout << '[' << clientip << ':' << clientport << "]# " << buffer << endl;
// write(sockfd, buffer, size);
// std::cout << 1 <<std::endl;
// }
// }
}
public:
int listensock_;
uint16_t port_;
std::string ip_;
};
5、发起链接 connect
- 客户端需要调用 connect() 连接服务器。
- connect 和 bind 的参数形式一致,区别在于 bind 的参数是自己的地址,而 connect 的参数是对方的地址。
connect() 成功返回 0,出错返回 -1。
这里的 addr 和 addrlen 填入的是服务端信息。
在 UDP 通信中,客户端在 sendto 时会自动绑定 IP 和 port,而 TCP 就是在 connect 的时候进行绑定。因为 connect 是系统调用接口,所以在调用 connect 时会自动的给绑定当前客户端的 ip 和 port,进而可以让我们在后续使用 sockfd 进行通信。
客户端.cpp
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <netinet/in.h>
using std::cout;
using std::endl;
void Usage(const std::string &proc)
{
std::cout << "\n\rUsage: " << proc << " serverip serverport\n"
<< std::endl;
}
// ./tcpclient serverip serverport
int main(int argc, char *argv[])
{
if (argc != 3)
{
Usage(argv[0]);
exit(1);
}
std::string serverip = argv[1];
uint16_t serverport = std::stoi(argv[2]);
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(serverport);
inet_pton(AF_INET, serverip.c_str(), &(server.sin_addr));
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0)
{
std::cerr << "socket error" << std::endl;
return 1;
}
int n = connect(sockfd, (struct sockaddr *)&server, sizeof(server));
if (n < 0)
{
std::cerr << "connect error..." << std::endl;
exit(2);
}
cout << "connect sunccess!" << endl;
while (true)
{
while (true)
{
std::string message;
std::cout << "Please Enter# ";
std::getline(std::cin, message);
int n = write(sockfd, message.c_str(), message.size());
if (n < 0)
{
std::cerr << "write error..." << std::endl;
// break;
}
char inbuffer[4096];
n = read(sockfd, inbuffer, sizeof(inbuffer));
if (n > 0)
{
inbuffer[n] = 0;
std::cout << inbuffer << std::endl;
}
else
{
// break;
}
}
close(sockfd);
}
return 0;
}
运行结果如下:
二、TCP 协议通讯流程
下图是基于 TCP 协议的客户端/服务器程序的一般流程:
1、服务器初始化
- 调用 socket,创建文件描述符。
- 调用 bind,将当前的文件描述符和 ip/port 绑定在一起,如果这个端口已经被其他进程占用了,就会 bind 失败。
- 调用 listen,声明当前这个文件描述符作为一个服务器的文件描述符,为后面的 accept 做好准备。
- 调用 accecpt,并阻塞,等待客户端连接过来。
2、建立连接的过程
- 调用 socket,创建文件描述符。
- 调用 connect,向服务器发起连接请求。
- connect 会发出 SYN 段并阻塞等待服务器应答(第一次)。
- 服务器收到客户端的 SYN,会应答一个 SYN-ACK 段表示 “同意建立连接”(第二次)。
- 客户端收到 SYN-ACK 后会从 connect() 返回,同时应答一个 ACK 段(第三次)。
TCP 是面向连接的通信协议,在通信之前需要进行 3 次握手,来进行连接的建立。这个建立连接的过程通常称为 三次握手
3、数据传输的过程
- 建立连接后,TCP 协议提供全双工的通信服务。所谓全双工的意思是,在同一条连接中, 同一时刻,通信双方可以同时写数据。相对的概念叫做半双工,同一条连接在同一时刻,只能由一方来写数据。
- 服务器从 accept() 返回后立刻调用 read(),读 socket 就像读管道一样,如果没有数据到达就阻塞等待。
- 这时客户端调用 write() 发送请求给服务器, 服务器收到后从 read() 返回,对客户端的请求进行处理,在此期间客户端调用 read()阻塞等待服务器的应答;
- 服务器调用 write() 将处理结果发回给客户端,再次调用 read() 阻塞等待下一条请求。
- 客户端收到后从 read() 返回,发送下一条请求,如此循环下去。
4、断开连接的过程
- 如果客户端没有更多的请求了,就调用 close() 关闭连接,客户端会向服务器发送 FIN 段(第一次)。
- 此时服务器收到 FIN 后,会回应一个 ACK,同时 read 会返回 0(第二次)。
- read 返回之后,服务器就知道客户端关闭了连接, 也调用 close 关闭连接,这个时候服务器会向客户端发送一个 FIN(第三次)。
- 客户端收到 FIN,再返回一个 ACK 给服务器(第四次)。
当 TCP 断开连接这个断开连接的过程 , 通常称为 四次挥手 。
为什么是四次挥手呢?
因为 TCP 是基于确定应答来保证单项可靠性的,如果对方给我发消息,我也给对方进行应答,那么就能够保证双向的可靠性。所以,发出去的断开连接的过程需要应答。
当客户端断开连接时,要保证客户端到服务的连接被成功关闭,所以需要调用一次,而服务端除了要释放自身创建好的文件描述符,也要关闭从服务端到客户端对应的连接,因为双方都要调用 close() 各自两次,那么一来一来就绪各自需要两次挥手,加起来就是四次挥手。
三、总结
对比 UDP 服务器,TCP 服务器多了获取新链接和监听的操作,而因为 TCP 是面向字节流的,所以接收和发送数据都是 IO 操作,也就是文件操作。
1、TCP 和 UDP 对比
- 可靠传输 VS 不可靠传输
- 有连接 VS 无连接
- 字节流 VS 数据报