推荐先学习UDP协议在学习TCP协议
在UDP协议博客中讲解得更详细,看懂UDP协议就很容易理解TCP了↓↓↓
网络 UDP协议(C++|代码通过udp协议实现客户端与服务端之间的通信)
TCP通信编程
tcp是面向连接、可靠传输、面向字节流的传输层协议
面向连接:必须建立了连接且保证双方都具有数据收发的能力,才能开始通信。(udp是无连接的,只要知道对端地址就可以直接发送消息)
可靠传输:传送的数据,无差错、不丢失、不重复、并且按序到达
面向字节流:
通信方面也是分为客户端和服务端
各端的操作流程:
服务端操作流程:
- 创建套接字端口:在内核中创建socket结构体,关联进程与网卡之间的联系
- 为套接字绑定地址信息:网络通信中的数据都必须带有源端IP、源端端口、对端IP、对端端口、协议,这5个信息称为五元组。在内核创建的socket结构体中描述IP地址端口以及协议,(必须主动绑定,告诉客户端自己的地址信息,如果不绑定客户端就不知道该发往哪个服务端了)为了告诉操作系统发往哪个IP地址,哪个端口的数据是交给我来处理的
- 开始监听:设置套接字的一个监听状态,只有处于监听状态的套接字才会接收客户端的连接请求。服务端会为每一个客户端的连接请求都创建一个新的socket结构体,通过这个新建的socket结构体与客户端进行通信
- 获取一个新建立连接的socket的描述符:获取到socket的操作句柄,通过这个指定的socket的操作句柄与指定的客户端进行通信
- 收发数据:每个新建的套接字包含了完整的五元组,知道自己与谁通信,因此收发数据的时候就不用设置地址信息了。
- 关闭套接字:释放资源
客户端操作流程:
- 创建套接字:在内核中创建socket结构体,关联进程与网卡之间的联系
- 为套接字绑定地址信息:描述在内核中创建的socket结构体的源端地址信息;发送的数据中源端地址信息就是绑定的地址信息(不推荐主动绑定地址,降低端口冲突的概率,从而确保数据发送的安全性)
- 向服务端发起连接请求:当服务端处于监听状态时就可以进行连接;但是当服务端不处于监听状态,请求会丢失
- 收发数据:被服务端特定套接字服务
- 关闭套接字:释放资源
举一个足疗店的例子来帮助你理解服务端与客户端之间的通信
服务端接口信息
1、创建套接字int socket(int domain, int type, int protocol)
参数内容(domian:地址域(本地通信-AF_LOCAL
、IPv4-AF_INET
、IPv6-AF_INET6
等)确定本次socket通信使用哪种协议版本的地址结构,不同的协议版本有不同的地址结构;type:套接字类型(流式套接字-SOCK_STREAM
、数据报套接字-SOCK_DGRAM
等);protocol:协议类型(TCP-IPPROTO_TCP
、UDP-IPPROTO_UDP
) ,默认为0-流式默认TCP,数据报默认UDP)
返回值:文件描述符-非负整数, 套接字所有其他接口的操作句柄,失败返回-1
2、为套接字绑定地址信息int bind(int sockfd, struct sockaddr *addr, socklen_t len)
参数内容(sockfd:创建套接字返回的操作句柄;addr:要绑定的地址信息;len:要绑定的地址信息长度)
3、开始监听listen(int sockfd, int backlog)
参数内容(sockfd:将sockfd的套接字设置为监听状态,并且监听状态后可以开始接收客户端连接请求;backlog:同一时间的并发连接数,决定同一时间最多接收多少个客户端的连接请求<内核中可创建套接字数量是有限的,防止存在恶意请求导致资源耗尽>)
4、获取新建连接,从已完成连接队列中取出一个socket,并且返回这个socket的描述符操作句柄int accept(int sockfd, struct sockaddr* cli_addr, socklen_t *len)
参数内容(sockfd:表示获取哪个tcp服务端套接字的新建连接;cli_addr:这个新建的套接字对应的客户端地址信息;len:地址信息长度) 返回值:新建的socket套接字的描述符,也就是外部进程中对该套接字的操作句柄
5、收发数据。在tcp套接字中已经标示了五元组,因此接收数据时不需要获取对方地址信息,发送数据时也不需要指定对方的地址信息。接收:ssize_t recv(int sockfd, char *buf, int len, int flag)
返回值:成功返回接收数据的长度,等于0表示断开连接,小于0表示出错。发送:ssize_t send(int sockfd, char *data, int len, int flag)
:返回值:成功返回发送数据的长度,等于0表示断开连接,小于0表示出错。若断开连接触发异常信号SIGPIPE
6、关闭套接字int close(fd)
客户端接口信息:将服务端的2、3、4步去掉,合成一步
1、创建套接字int socket(int domain, int type, int protocol)
2、向服务端发起连接请求int connect(int sockfd, struct sockaddr *srv_addr, int len)
参数内容(sockfd:哪个服务端发送请求连接;src_addr:服务端地址信息,给该服务端发送请求)这个connect接口也会在sockfd客户端的套接字socket中描述对端的地址信息
3、收发数据。接收:ssize_t recv(int sockfd, char *buf, int len, int flag)
发送:ssize_t send(int sockfd, char *data, int len, int flag)
4、关闭套接字int close(fd)
代码实现
创建一个类用于封装各端的操作
tcpsocket.hpp
//tcpsocket.hpp
#include <cstdio>
#include <unistd.h>
#include <string>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/socket.h>
using namespace std;
//该值表示用一时间能够接收多少客户端连接
//并非指整个通信最多接收多少客户端连接
#define MAX_LISTEN 5
#define CHECK_RET(q) if((q) == false){return -1;}
class TcpSocket
{
public:
TcpSocket()
:_sockfd(-1)
{}
bool Socket()
{
_sockfd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (_sockfd < 0)
{
perror("socket error");
return false;
}
return true;
}
bool Bind(const string &ip, uint16_t port)
{
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
addr.sin_addr.s_addr = inet_addr(ip.c_str());
socklen_t len = sizeof(struct sockaddr_in);
int ret = bind(_sockfd, (struct sockaddr*)&addr, len);
if (ret < 0)
{
perror("bind error");
return false;
}
return true;
}
bool Listen(int backlog = MAX_LISTEN)
{
int ret = listen(_sockfd, backlog);
if (ret < 0)
{
perror("listen error");
return false;
}
return true;
}
bool Accept(TcpSocket *new_sock, string *ip = NULL, uint16_t *port = NULL)
{
struct sockaddr_in addr;
socklen_t len = sizeof(struct sockaddr_in);
int new_fd = accept(_sockfd, (struct sockaddr*)&addr, &len);
cout << "dsdsdds";
if (new_fd < 0)
{
perror("accept error");
return false;
}
new_sock->_sockfd = new_fd;
if (ip != NULL)
{
*ip = inet_ntoa(addr.sin_addr);
}
if (port != NULL)
{
*port = ntohs(addr.sin_port);
}
return true;
}
bool Recv(string *buf)
{
char tmp[4096] = {0};
int ret = recv(_sockfd, tmp, 4096, 0);
if (ret < 0)
{
perror("recv error");
return false;
}
else if (ret == 0)//默认阻塞,没有数据就会等待,返回0表示连接断开
{
printf("connection broken\n");
return false;
}
buf->assign(tmp, ret);
return true;
}
bool Send(const string &data)
{
int ret = send(_sockfd, data.c_str(), data.size(), 0);
if (ret < 0)
{
perror("send error");
return false;
}
return true;
}
bool Close()
{
if (_sockfd > 0)
{
close(_sockfd);
_sockfd = -1;
}
return true;
}
bool Connect(const string &ip, uint16_t port)
{
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
addr.sin_addr.s_addr = inet_addr(ip.c_str());
socklen_t len = sizeof(struct sockaddr_in);
int ret = connect(_sockfd, (struct sockaddr*)&addr, len);
if (ret < 0)
{
perror("connect error");
return false;
}
return true;
}
private:
int _sockfd;
};
tcp_srv.cpp
//tcp_srv.cpp
#include <cstdio>
#include <iostream>
#include "tcpsocket.hpp"
using namespace std;
int main(int argc, char *argv[])
{
if (argc != 3)
{
cout << "Usage:./tcp_srv ip port" << endl;
return -1;
}
string ip = argv[1];
uint16_t port = stoi(argv[2]);
TcpSocket lst_sock;
//穿件套接字
CHECK_RET(lst_sock.Socket());
//为套接字绑定地址信息
CHECK_RET(lst_sock.Bind(ip, port));
//开始监听
CHECK_RET(lst_sock.Listen());
while (1)
{
TcpSocket new_sock;
//获取连接
bool ret = lst_sock.Accept(&new_sock);
if (ret == false)
{
continue;//服务端不能因为获取一个新建套接字失败就退出
}
string buf;
new_sock.Recv(&buf);
cout << "client say: " << buf << endl;
buf.clear();
cout << "server say: ";
cin >> buf;
new_sock.Send(buf);
}
lst_sock.Close();
return 0;
}
tcp_cli.cpp
//tcp_cli.cpp
#include <iostream>
#include <string>
#include "tcpsocket.hpp"
using namespace std;
int main(int argc, char *argv[])
{
if (argc != 3)
{
cout << "Usage: ./tcp_cli ip port" << endl;
return -1;
}
string srv_ip = argv[1];
uint16_t srv_port = stoi(argv[2]);
TcpSocket sock;
CHECK_RET(sock.Socket());
CHECK_RET(sock.Connect(srv_ip, srv_port));
while (1)
{
string buf;
cout << "client say: ";
cin >> buf;
sock.Send(buf);
buf.clear();
sock.Recv(&buf);
cout << "server say: "<< buf << endl;
}
sock.Close();
return 0;
}
makefile
all:tcp_srv tcp_cli
tcp_srv:tcp_srv.cpp
g++ -std=c++11 $^ -o $@
tcp_cli:tcp_cli.cpp
g++ -std=c++11 $^ -o $@
查看网卡信息
先运行服务端,等待新的连接。
运行客户端,并发送消息
服务端收到消息并回复
客户端收到消息并回复
但是此时客户端并没有接收到客户端发来的消息,一直停留在上一次发送消息完后的样子
简单分析服务端while循环,while循环中第一步获取连接时阻塞等待的,当一个新的客户端来是会为它创建一个新的套接字与它通信。客户端第一次发送消息,服务端走到第二步接收消息,再走到第三步发送消息。发送完消息第一次循环就结束了,又重新到了第一步等待获取连接。此时新创建的套接字丢失,所以此时客户端再给服务端发送消息,就无法接收。
这时候我们就得引入多线程或者多进程来完成这项任务。在获取到一个新的连接时,就启动一个新的执行流,让这个新的执行流去与该客户端进行通信。这样子做的好处是:没有了因为新连接到来的阻塞,就不会影响与客户端之间的通信;与客户端通信时的阻塞,并不会影响获取新的连接
多进程使用TCP实现通信
只要修改服务端
tcp_process.cpp
//tcp_process.cpp
#include <cstdio>
#include <iostream>
#include <signal.h>
#include <sys/wait.h>
#include "tcpsocket.hpp"
using namespace std;
void sigcb(int no)
{
while (waitpid(-1, NULL, WNOHANG) > 0);
}
int main(int argc, char *argv[])
{
if (argc != 3)
{
cout << "Usage:./tcp_srv ip port" << endl;
return -1;
}
//子进程退出处理方式
signal(SIGCHLD, sigcb);
string ip = argv[1];
uint16_t port = stoi(argv[2]);
TcpSocket lst_sock;
//穿件套接字
CHECK_RET(lst_sock.Socket());
//为套接字绑定地址信息
CHECK_RET(lst_sock.Bind(ip, port));
//开始监听
CHECK_RET(lst_sock.Listen());
while (1)
{
TcpSocket new_sock;
//获取连接
bool ret = lst_sock.Accept(&new_sock);
if (ret == false)
{
continue;//服务端不能因为获取一个新建套接字失败就退出
}
//创建子进程
int pid = fork();
if (pid == 0)
{
while (1)
{
string buf;
new_sock.Recv(&buf);
cout << "client say: " << buf << endl;
buf.clear();
cout << "server say: ";
cin >> buf;
new_sock.Send(buf);
}
new_sock.Close();
exit(0);
}
new_sock.Close();//父进程关闭自己不使用的socket,不影响子进程
}
lst_sock.Close();
return 0;
}
先执行服务端
再执行客户端并发送消息
服务端收到消息并回复
客户端收到并发送消息
服务端收到消息并回复
我们发现都已经正常完成tcp的通信了!
使用多进程实现注意事项:父子进程数据独有,父进程用不到新建套接字,要记得关闭;进程等待使用信号处理,不再阻塞父进程
多线程使用TCP实现通信
多线程也就是穿件一个新的线程,让该线程专门负责去与新建连接的客户去通信,原理和多进程差不多。但是存在一个问题,线程如何拿到新建的套接字对象呢?new_sock是一个局部对象,循环完毕的时候就会被释放,传地址就会造成错误。两种方案:1、每次accept的时候new_sock在堆上申请一个新的,不会被自动释放,但是也要防止第二次获取的时候覆盖上次的值;2、或者直接传值–在函数栈中会新建空间存储值,仅限于数据占用空间比较小,防止栈溢出
我们接下来演示第二种方式将获取连接的套接字传到线程入口函数中,但是最后一个参数必须是void*
类型的,我们并不能直接将对象当做值直接传过去。我们知道对象中只有一个属性,也就是套接字的操作句柄,我们只要将它传过去,就能利用该操作句柄创建一个一样的套接字。这样子就可以拿到获取连接时的套接字了。但是我们必须在封装套接字的类中额外提供两个函数,一个是获取操作句柄的函数GetFd
和设置操作句柄的函数SetFd
。SetFd函数时在线程穿建套接字的时候使用的。在线程中穿建一个新的套接字,将这个套接字的操作句柄修改为传过来的值。就能操作原先的套接字了
tcpsocket.hpp (只提供修改部分)
//tcpsocket.hpp
int GetFd()
{
return _sockfd;
}
void SetFd(int fd)
{
_sockfd = fd;
}
tcp_thread.cpp
#include <cstdio>
#include <iostream>
#include <pthread.h>
#include "tcpsocket.hpp"
using namespace std;
//线程入口函数,赋值收发数据通信
void* thr_start(void *arg)
{
long fd = (long)arg;
TcpSocket new_sock;
new_sock.SetFd(fd);
while (1)
{
string buf;
new_sock.Recv(&buf);
cout << "client say: " << buf << endl;
buf.clear();
cout << "server say: ";
cin >> buf;
new_sock.Send(buf);
}
new_sock.Close();//线程之间文件描述符表共享,这边关闭了描述符其他地方就用不了了
return NULL;
}
int main(int argc, char *argv[])
{
if (argc != 3)
{
cout << "Usage:./tcp_srv ip port" << endl;
return -1;
}
string ip = argv[1];
uint16_t port = stoi(argv[2]);
TcpSocket lst_sock;
//穿件套接字
CHECK_RET(lst_sock.Socket());
//为套接字绑定地址信息
CHECK_RET(lst_sock.Bind(ip, port));
//开始监听
CHECK_RET(lst_sock.Listen());
while (1)
{
TcpSocket new_sock;
//获取连接
bool ret = lst_sock.Accept(&new_sock);
if (ret == false)
{
continue;//服务端不能因为获取一个新建套接字失败就退出
}
//线程
pthread_t tid;
int res = pthread_create(&tid, NULL, thr_start, (void*)new_sock.GetFd());
if (res != 0)
{
cout << "pthread create error" << endl;
continue;
}
pthread_detach(tid);//不关心线程返回值,也不想等待释放资源,因此将线程分离出去
}
lst_sock.Close();
return 0;
}
makefile
all:tcp_thread tcp_cli
tcp_thread:tcp_thread.cpp
g++ -std=c++11 $^ -o $@ -lpthread
udp_cli:udp_cli.cpp
g++ -std=c++11 $^ -o $@
先运行服务端
再运行客户端并发送消息
服务端收到消息后回复
客户端收到并发送消息
服务端收到并回复
多线程实现注意事项:主线程创建线程之后,千万不能关闭新建套接字,因为线程之间共享文件描述符表。
服务端既可以通过多进程来实现,也可以通过多线程来实现,那哪种方式更合适呢?
不同场景应用不同方式解决:多线程灵活,资源占用少,通信方便,但是健壮性没多进程强;多进程资源占用多,但是安全,健壮性高。
若服务端针对客户端的业务比较简单,使用多线程,效率高且通信方便;若服务端针对客户端的业务比较复杂,使用多进程,安全性和健壮性高。