前言
在当今数字化时代,网络通信作为连接世界的桥梁,成为计算机科学领域中至关重要的一部分。理解网络编程是每一位程序员必备的技能之一,而掌握套接字编程则是深入了解网络通信的关键。本博客将深入讨论套接字编程中的基本概念、常见API以及实际应用,通过一步步的学习,帮助读者逐渐掌握网络编程的精髓。
一、预备知识
1.1 IP
IP 是全球网络的基础,使用 IP
地址来标识公网环境下主机的唯一性,我们可以根据 目的IP地址 进行跨路由器的远端通信
仅仅使用 IP
只能定位到目标主机,并且目标主机不是最终目的地,要想定位目的地,需要依靠 端口号
目标主机中存在很多进程,网络通信实际是不同主机中的进程在进行通信,并非主机与主机直接通信
- IP:源地址 与 目标地址
- Port:目标地址中的哪个进程
1.2 端口号
端口号 是一个用于标识网络进程唯一性的标识符,是一个 2
字节的整数,取值范围为 [0, 65535]
,可以通过 端口号 定位主机中的目标进程
抛开网络其他知识,将信息从主机 A
中的进程 A
发送至主机 B
中的 进程 B
,这不就是 进程间通信 吗?之前学习的 进程间通信 是通过 匿名管道、命名管道、共享内存 等方式实现,而如今的 进程间通信 则是通过 网络传输 的方式实现
需要进行网络通信的进程有很多,为了方便进行管理,就诞生了 端口号 这个概念,同进程的 PID
一样,端口号 也可以用于标识进程
1.3 端口号与进程PID
端口号 用于标识进程,进程 PID
也是用于标识进程,为什么在网络中,不直接使用进程 PID
呢?
- 进程
PID
隶属于操作系统中的进程管理,如果在网络中使用PID
,会导致网络标准中被迫中引入进程管理相关概念(进程管理与网络强耦合) - 进程管理 属于
OS
内部中的功能,OS
可以有很多标准,但网络标准只能有一套,在网络中直接使用PID
无法确保网络标准的统一性 - 并不是所有的进程都需要进行网络通信,如果端口号、
PID
都使用同一个解决方案,无疑会影响网络管理的效率
所以综上所述,网络中的 端口号 需要通过一种全新的方式实现,也就是一个 2
字节的整数 port
,进程 A
运行后,可以给它绑定 端口号 N
,在进行网络通信时,根据 端口号 N
来确定信息是交给进程 A
的
所以将之前的结论再具体一点:IP + Port 可以标识公网环境下,唯一的网络进程
网络传输中的必备信息组 [目的
IP
源IP
|| 目的Port
源Port
]
- 目的
IP
:需要把信息发送到哪一台主机- 源
IP
:信息从哪台主机中发出- 目的
Port
:将信息交给哪一个进程- 源
Port
:信息从哪一个进程中发出
注意: 端口号与进程 PID
并不是同一个概念
进程
PID
就好比你的身份证,端口号 相当于学号,这两个信息都可以标识唯一的你,但对于学校来说,使用学号更方便进行管理
一个进程可以绑定多个 端口号 吗?一个 端口号 可以被多个进程绑定吗?
端口号 的作用是配合 IP
地址标识网络世界中进程的唯一性,如果一个进程绑定多个 端口号,依然可以保证唯一性(因为无论使用哪个 端口号,信息始终只会交给一个进程);但如果一个 端口号 被多个进程绑定了,在信息递达时,是无法分辨该信息的最终目的进程的,存在二义性
所以一个进程可以绑定多个端口号,一个 端口号 不允许被多个进程绑定,如果被绑定了,可以通过 端口号 顺藤摸瓜,找到占用该 端口号 的进程
如果某个端口号被使用了,其他进程再继续绑定是会报错的,提示 该端口已被占用
主机(操作系统)是如何根据 端口号 定位具体进程的?
这个实现起来比较简单,创建一张哈希表,维护 <端口号, 进程 PID
> 之间的映射关系,当信息通过网络传输到目标主机时,操作系统可以根据其中的 [目的 Port
],直接定位到具体的进程 PID
,然后进行通信
1.4 传输层协议
主流的传输层协议有两个:TCP
和 UDP
两个协议各有优缺点,可以采用不同的协议,实现截然不同的网络程序,关于 TCP
和 UDP
的详细信息将会放到后面的博客中详谈,先来看看简单这两种协议的特点
TCP
协议:传输控制协议
- 传输层协议
- 有连接
- 可靠传输
- 面向字节流
字节流就像水龙头,用户可以根据自己的需求获取水流量
UDP
协议:用户数据协议
- 传输层协议
- 无连接
- 不可靠传输
- 面向数据报
数据报则是相当于包裹,用户每次获取的都是一个或多个完整的包裹
关于 可靠性
TCP 的可靠传输并不意味着它可以将数据百分百递达,而是说它在数据传输过程中,如果发生了传输失败的情况,它会通过自己独特的机制,重新发送数据,确保对端百分百能收到数据;至于 UDP 就不一样,数据发出后,如果失败了,也不会进行重传,好在 UDP 面向数据报,并且没有很多复杂的机制,所以传输速度很快
总结起来就是:TCP
用于对数据传输要求较高的领域,比如金融交易、网页请求、文件传输等,至于 UDP
可以用于短视频、直播、即时通讯等对传输速度要求较高的领域
如果不知道该使用哪种协议,优先考虑
TCP
,如果对传输速度又要求,可以选择UDP
1.5 网络字节序
在学习网络字节序相关知识前,先回顾一下大小端字节序
- 数据拥有高权值位和低权值位,比如在
32
位操作系统中,十六进制数0x11223344
,其中的11
称为 最高权值位,44
称为 最低权值位 - 内存有高地址和低地址之分
如果将数据的高权值存放在内存的低地址处,低权值存放在高地址处,此时就称为 大端字节序,反之则称为 小端字节序,这两种字节序没有好坏之分,只是系统设计者的使用习惯问题,比如我当前的电脑在存储数据时,采用的就是 小端字节序 方案
通过内存单元可以看到,使用 小端字节序 时数据是倒着放的,大端字节序 就是正着存放了
在网络出现之前,使用大端或小端存储都没有问题,网络出现之后,就需要考虑使用同一种存储方案了,因为网络通信时,两台主机存储方案可能不同,会出现无法解读对方数据的问题
顶层设计者采用了解决方案2,TCP/IP 协议规定:网络中传输的数据,统一采用大端存储方案,也就是网络字节序, 现在大端/小端称为 主机字节序
发送数据时,将 主机字节序 转化为 网络字节序,接收到数据后,再转回 主机字节序 就好了,完美解决不同机器中的大小端差异,可以用下面这批库函数进行转换,在发送/接收时,调用库函数进行转换即可
#include <arpa/inet.h>
// 主机字节序转网络字节序
uint32_t htonl(uint32_t hostlong); // l 表示32位长整数
uint32_t htons(uint32_t hostshort); // s 表示16位短整数
// 网络字节序转主机字节序
uint32_t ntohl(uint32_t netlong); // l 表示32位长整数
uint32_t ntohs(uint32_t netshort); // s 表示16位短整数
二、 socket套接字
2.1 socket 常见API
socket
套接字提供了下面这一批常用接口,用于实现网络通信
#include <sys/types.h>
#include <sys/socket.h>
// 创建socket文件描述符(TCP/UDP 服务器/客户端)
int socket(int domain, int type, int protocol);
// 绑定端口号(TCP/UDP 服务器)
int bind(int socket, const struct sockaddr* address, socklen_t address_len);
// 开始监听socket (TCP 服务器)
int listen(int socket, int backlog);
// 接收连接请求 (TCP 服务器)
int accept(int socket, struct sockaddr* address, socklen_t* address_len);
// 建立连接 (TCP 客户端)
int connect(int sockfd, const struct sockaddr* addr, socklen_t addrlen);
可以看到在这一批 API
中,频繁出现了一个结构体类型 sockaddr
,该结构体支持网络通信,也支持本地通信
socket
套接字就是用于描述 sockaddr
结构体的字段,复用了文件描述符的解决方案
2.2 sockaddr 结构体
socket
这套网络通信标准隶属于 POSIX
通信标准,该标准的设计初衷就是为了实现 可移植性,程序可以直接在使用该标准的不同机器中运行,但有的机器使用的是网络通信,有的则是使用本地通信,socket
套接字为了能同时兼顾这两种通信方式,提供了 sockaddr
结构体
由 sockaddr
结构体衍生出了两个不同的结构体:sockaddr_in
网络套接字、sockaddr_un
域间套接字,前者用于网络通信,后者用于本地通信
- 可以根据
16
位地址类型,判断是网络通信,还是本地通信 - 在进行网络通信时,需要提供
IP
地址、端口号 等网络通信必备项,本地通信只需要提供一个路径名,通过文件读写的方式进行通信(类似于命名管道)
socket
提供的接口参数为 sockaddr*
,我们既可以传入 &sockaddr_in
进行网络通信,也可以传入 &sockaddr_un
进行本地通信,传参时将参数进行强制类型转换即可,这是使用 C语言 实现 多态 的典型做法,确保该标准的通用性
三、UDP套接字程序
该图引用于此,套接字运用流程图
3.1 简易本地通讯及bash模拟
分别实现客户端与服务器,客户端向服务器发送消息,服务器收到消息后,回响给客户端,有点类似于 echo
指令
该程序的核心在于 使用 socket
套接字接口,以 UDP
协议的方式实现简单网络通信
程序由 server.hpp
、server.cc
、client.hpp
、client.cc
组成,大体框架如下
server.hpp头文件
server.cc文件
client.hpp头文件
client.cc文件
Makefile文件
四、TCP套接字程序
4.1 多进程版本地通信
#include<iostream>
#include<string>
#include<sys/socket.h>
#include<sys/types.h>
#include<netinet/in.h>
#include<arpa/inet.h>
#include<cstring>
#include<unistd.h>
#include<signal.h>
using namespace std;
void Usage(string proc)
{
cout << "Usage: " << proc << "port" << endl;
}
void ServiceIO(int new_sock)
{
// 提供服务
while(true)
{
char buffer[1024];
memset(buffer, 0, sizeof(buffer));
ssize_t cnt = read(new_sock, buffer, sizeof(buffer) - 1);
if(cnt > 0)
{
buffer[cnt] = 0;
cout << "client# " << buffer << endl;
string echo_string = "server--> ";
echo_string += buffer;
write(new_sock, echo_string.c_str(), echo_string.size());
}
else if(cnt == 0)
{
cout << "client quit..." << endl;
break;
}
else
{
cerr << "read error" << endl;
break;
}
}
}
int main(int argc, char* argv[])
{
if(argc != 2)
{
Usage(argv[0]);
return 1;
}
// 1.创建监听套接字
int listen_sock = socket(AF_INET, SOCK_STREAM, 0); // ipv4 , 流式套接
if(listen_sock < 0)
{
cerr << "socket error: " << errno << endl;
return 2;
}
// bind
// 填充套接字信息
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(atoi(argv[1]));
local.sin_addr.s_addr = INADDR_ANY;
if(bind(listen_sock, (struct sockaddr*)&local, sizeof(local)) < 0)
{
cerr << "bind error" << errno << endl;
return 3;
}
// 监听
const int back_log = 5;
if (listen(listen_sock, back_log) < 0)
{
cerr << "listen error" << errno << endl;
return 4;
}
signal(SIGCHLD, SIG_IGN); // 在linux中父进程忽略子进程的SIGCHLD信号,子进程会自动退出释放资源
while(true)
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int new_sock = accept(listen_sock, (struct sockaddr *)&peer, &len);
// 上面创建的监听套接字只起到监听作用
// accept接口返回的套接字,才能为客户端提供服务
if(new_sock < 0)
{
continue;
}
// peer: 输入输出型参数,存放客户端sock
// 拿到客户端ip地址及端口:
uint16_t client_port = ntohs(peer.sin_port); // 网络序列转主机序列
string client_ip = inet_ntoa(peer.sin_addr); // inet_ntoa:将四字节ip转化成点分十进制ip
cout << "get a new link -> : [" << client_ip << ":" << client_port << "]#" << new_sock << endl;
pid_t id = fork();
if(id<0)
{
continue;
}
else if(id==0) // 子进程会继承父进程的文件描述符, 为防止出现文件描述符泄露,需要关闭不用的文件描述符
{
// child
close(listen_sock);
ServiceIO(new_sock);
close(new_sock); // 关闭文件描述符,否则会导致文件描述符泄露
exit(0);
}
else
{
// father
// do nothing
close(new_sock); // 父进程关闭提供服务的文件描述符,继续接收新链接
}
}
return 0;
}
4.2 多线程版
#include<iostream>
#include<string>
#include<sys/socket.h>
#include<sys/types.h>
#include<netinet/in.h>
#include<arpa/inet.h>
#include<cstring>
#include<unistd.h>
#include<signal.h>
#include<pthread.h>
using namespace std;
void Usage(string proc)
{
cout << "Usage: " << proc << "port" << endl;
}
void ServiceIO(int new_sock)
{
// 提供服务
while(true)
{
char buffer[1024];
memset(buffer, 0, sizeof(buffer));
ssize_t cnt = read(new_sock, buffer, sizeof(buffer) - 1);
if(cnt > 0)
{
buffer[cnt] = 0;
cout << "client# " << buffer << endl;
string echo_string = "server--> ";
echo_string += buffer;
write(new_sock, echo_string.c_str(), echo_string.size());
}
else if(cnt == 0)
{
cout << "client quit..." << endl;
break;
}
else
{
cerr << "read error" << endl;
break;
}
}
}
void* HandlerRequest(void*args)
{
pthread_detach(pthread_self()); // 分离新线程
int sock = *(int *)args;
delete (args);
ServiceIO(sock);
close(sock);
}
int main(int argc, char* argv[])
{
if(argc != 2)
{
Usage(argv[0]);
return 1;
}
// 1.创建监听套接字
int listen_sock = socket(AF_INET, SOCK_STREAM, 0); // ipv4 , 流式套接
if(listen_sock < 0)
{
cerr << "socket error: " << errno << endl;
return 2;
}
// bind
// 填充套接字信息
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(atoi(argv[1]));
local.sin_addr.s_addr = INADDR_ANY;
if(bind(listen_sock, (struct sockaddr*)&local, sizeof(local)) < 0)
{
cerr << "bind error" << errno << endl;
return 3;
}
// 监听
const int back_log = 5;
if (listen(listen_sock, back_log) < 0)
{
cerr << "listen error" << errno << endl;
return 4;
}
signal(SIGCHLD, SIG_IGN); // 在linux中父进程忽略子进程的SIGCHLD信号,子进程会自动退出释放资源
while(true)
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int new_sock = accept(listen_sock, (struct sockaddr *)&peer, &len);
// 上面创建的监听套接字只起到监听作用
// accept接口返回的套接字,才能为客户端提供服务
if(new_sock < 0)
{
continue;
}
// peer: 输入输出型参数,存放客户端sock
// 拿到客户端ip地址及端口:
uint16_t client_port = ntohs(peer.sin_port); // 网络序列转主机序列
string client_ip = inet_ntoa(peer.sin_addr); // inet_ntoa:将四字节ip转化成点分十进制ip
cout << "get a new link -> : [" << client_ip << ":" << client_port << "]#" << new_sock << endl;
// 创建新线程
pthread_t tid;
int *pram = new int(new_sock);
pthread_create(&tid, nullptr, HandlerRequest, pram);
// 这里主线程不需要关闭new_sock文件描述符,因为主线程与新线程共享一个文件描述符数组,主线程关闭new_sock会导致新线程也关闭
}
return 0;
}
4.3 线程池版
#include<iostream>
#include<string>
#include<sys/socket.h>
#include<sys/types.h>
#include<netinet/in.h>
#include<arpa/inet.h>
#include<cstring>
#include<unistd.h>
#include<signal.h>
#include<pthread.h>
#include"thread_pool.hpp"
#include"task.hpp"
using namespace std;
void Usage(string proc)
{
cout << "Usage: " << proc << "port" << endl;
}
int main(int argc, char* argv[])
{
if(argc != 2)
{
Usage(argv[0]);
return 1;
}
// 1.创建监听套接字
int listen_sock = socket(AF_INET, SOCK_STREAM, 0); // ipv4 , 流式套接
if(listen_sock < 0)
{
cerr << "socket error: " << errno << endl;
return 2;
}
// bind
// 填充套接字信息
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(atoi(argv[1]));
local.sin_addr.s_addr = INADDR_ANY;
if(bind(listen_sock, (struct sockaddr*)&local, sizeof(local)) < 0)
{
cerr << "bind error" << errno << endl;
return 3;
}
// 监听
const int back_log = 5;
if (listen(listen_sock, back_log) < 0)
{
cerr << "listen error" << errno << endl;
return 4;
}
signal(SIGCHLD, SIG_IGN); // 在linux中父进程忽略子进程的SIGCHLD信号,子进程会自动退出释放资源
while(true)
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int new_sock = accept(listen_sock, (struct sockaddr *)&peer, &len);
// 上面创建的监听套接字只起到监听作用
// accept接口返回的套接字,才能为客户端提供服务
if(new_sock < 0)
{
continue;
}
// peer: 输入输出型参数,存放客户端sock
// 拿到客户端ip地址及端口:
uint16_t client_port = ntohs(peer.sin_port); // 网络序列转主机序列
string client_ip = inet_ntoa(peer.sin_addr); // inet_ntoa:将四字节ip转化成点分十进制ip
cout << "get a new link -> : [" << client_ip << ":" << client_port << "]#" << new_sock << endl;
// 1.创建任务
Task t(new_sock);
// 2.将任务放进线程池
ThreadPool<Task>::GetInstance()->PushTask(t);
}
return 0;
}
五、总结
- 创建socket的过程(socket()),本质是打开文件。(仅有系统相关的内容)
- bind(),struct sockaddr_in -> ip,port,本质是ip+port和文件信息进行关联
- listen(),本质是设置该socket文件的状态,允许别人来连接我
- accpet(),获取新链接到应用层,是以fd为代表的;所谓的连接,在OS层面,本质其实就是一个描述连接的结构体(文件)
- read/write,本质就是进行网络通信,对于用户来讲就相当于在进行正常的文件读写
- close(fd),关闭文件;系统层面,释放曾经申请的文件资源,连接资源等;网络层面,通知对方,我的连接已经关闭了
- connect(),本质是发起连接,在系统层面,就是构建一个请求报文发送过去;网络层面,发起tcp连接的三次握手
- close(),client、server,本质在网络层面就是在进行四次挥手