Linux:TCP Socket编程(代码实战)
Posted It‘s so simple
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Linux:TCP Socket编程(代码实战)相关的知识,希望对你有一定的参考价值。
目录
1. TCP的Socket编程
1.1 TCP的编程流程
和UDP编程一样,我们在考虑TCP的时候也要考虑到接收方和发送方,也就是是说要考虑接收方和发送方的,但是由于TCP是有连接,可靠有序且面向字节流的传输层协议,因此,TCP的编程流程要比UDP复杂一些。
用图来表示的话如下:
1.2 TCP Socket的接口
创建套接字socket()、关闭套接字close()和绑定地址信息bind()这三个函数均和UDP的一样,可以看我之前写的这篇文章
1.2.1 服务端监听连接接口
int listen(int sockfd, int backlog);
参数:
sockfd
:套接字描述符backlog
:已完成连接队列的大小注:服务端和客户端在进行三次握手的过程中,会存在两个队列,一个是已完成连接的队列,一个是未完成连接的队列,未完成连接的队列中存储的是没有完成三次握手的连接;而已完成连接的队列中存储的是已经完成三次连接,并且等待被服务端"accept"的连接,或者也可以理解为当前连接状态为"ESTABLISHED"的连接。
对backlog
参数的具体理解:
由于TCP连接首先要进行三次握手,而想要和服务端建立连接的客户端却有很多个,在同一时间内可能会有多个客户端再和服务端进行三次握手。而backlog控制的就是已完成连接队列的大小,因此backlog影响了服务端在一瞬间的连接的接收能力,或者说影响了服务端并发接收新连接的能力,注意不能将其理解为 服务端建立连接的数量。
1.2.2 服务端获取新连接接口
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
作用:
从已完成连接队列中获取已经完成三次握手的连接。(当没有连接的时候,调用该接口会发送阻塞)
参数:
sockfd
:套接字描述符addr
:客户端的地址信息结构addrlen
:客户端的地址信息结构的长度
返回值:(非常重要)
成功则返回新连接的套接字描述符,失败则返回-1。
对accept
返回值的深入理解:
如图:
这就是为什么返回值如此重要原因,因为他关系着后面收发数据时所对应在服务端中的缓冲区时哪一个。
1.2.3 客户端发起连接接口
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数:
sockfd
:套接字描述符addr
:服务端的地址信息结构addrlen
:服务端的地址信息结构的长度
返回值:
成功则返回0,失败则返回小于0的数。
1.2.4 发送数据接口
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
参数:
sockfd
:套接字描述符buf
:待要发送的数据len
:待要发送数据的长度flags
:0为阻塞发送,MSG_OOB为发送带外数据
带外数据:即在紧急情况下所产生的数据,会越过前面进行排队的数据优先进行发送。
返回值:
成功则返回的字节数量,失败则返回-1。
1.2.5 接收数据接口
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
参数:
sockfd
:套接字描述符buf
:将接收到的数据放到buf中len
:接收数据的长度flags
:0为阻塞接收,即如果没有发送数据,则recv接口会阻塞
返回值:
>0
:正常接收了多少字节的数据==0
:对端将连接关闭(即对端机器调用了close函数关闭了套接字)>0
:接收失败
1.3 三次握手的简单验证
由于三次握手是操作系统内核自动完成的,因此我们只需要给出服务端的的代码到listen
接口即可。
#include <unistd.h>
#include <sys/socket.h>
#include <string.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <iostream>
using namespace std;
int main()
{
//1. 创建套接字
int sockfd = socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);
if(sockfd < 0)
{
cout << "TCP socket failed" << endl;
return 0;
}
//2.绑定地址信息
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(18989);
addr.sin_addr.s_addr = inet_addr("0.0.0.0");
int ret = bind(sockfd,(struct sockaddr *)&addr,sizeof(addr));
if(ret < 0)
{
cout << "bind failed" << endl;
return 0;
}
//3.进行侦听(告诉OS内核可以与客户端建立连接了)
//listen函数中blocklog参数,设定的是已完成三次握手并已建立连接的队列的容量
ret = listen(sockfd,1);
if(ret < 0)
{
cout << "listen failed" << endl;
return 0;
}
while(1)
{};
return 0;
}
要测试三次握手是否成功,我们不需要编写客户端代码进行验证,而是借助windows下的一个工具telnet
,telnet
可以模仿TCP进行三次握手,就只是单纯的建立连接,其他什么都不会做。
使用telnet + 公网ip + 端口号
即可对其进行测试,需要注意的是,这条命令是在windows下的cmd窗口运行的。
首先我们需要将服务端运行起来然后再使用telnet进行测试
当一回车之后,会新弹出一个telnet的窗口,代表三次握手成功。
下面给出两种失败的情况
情况1:端口不存在
情况2:公网ip错误
我们多使用telnet建立几次连接,然后看看当前服务器端口的使用情况。
这里总共使用telnet与当前服务器进行了三次的连接,即进行了三个三次握手,而我们在代码中设置的已完成连接的队列的大小为1,我们可以使用netstat -anp | grep 18989
查看18989端口的使用情况。
我们可以清楚的看到该服务端建立了两次连接,但是我们设置的backlog为1,按理说只能连接一次,那这是什么情况呢?原因是操作系统内核的问题,内核中的代码逻辑是这样的// q为 已完成连接队列 ,capacity_为当前我们设置的backlog的大小 if(q.size() < capacity_) { //不建立连接 }
因此,根据这个逻辑来分析,他会建立两次连接,是正常的,并不是我们代码的问题。
但是有一点需要注意的是,在验证三次握手的时候,我们服务器端的代码绝对不能用accept来接收,因为一旦用accept接收之后,就会从当前已完成队列中取走一个连接,导致我们看到的连接结果会多一条,如图:
2. 代码实战
本次TCP的代码有三种形式:单进程版本、多进程版本和多线程版本,但是这些版本均只是针对服务端而言,其中单进程和多进程的客户端的代码都是相同的,因此在这里我们首先给出客户端的代码。
#include <unistd.h>
#include <sys/socket.h>
#include <string.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <iostream>
using namespace std;
/*
* 1. 创建套接字
* 2. 建立连接请求
* 3. TCP三次握手建立连接----OS会自动进行,不需要我们进行控制
* 4. 接收数据、发送数据
* 5. 关闭套接字
*/
int main()
{
//1. 创建套接字
int sockfd = socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);
if(sockfd < 0)
{
cout << "TCP socket failed" << endl;
return 0;
}
//2.进行连接
//0.0.0.0:任意网卡地址,注意不是任一,它的含义就是哪个网卡地址都行
//当服务器端绑定的地址为0.0.0.0,那么我们客户端想要与服务端建立连接的时候
//有两种方法:
//1.若是运行服务器的这台机器本身是能够上网的,
// 那么我们只需要连接它的网卡地址中的任意一个就行,(0.0.0.0为任意地址)
//2.若是运行服务器的这台机器是要经过类似于腾讯云、阿里云这样的
// 云服务器读对私网ip进行转换才能上网的,那么我们就需要连接它的公网ip。
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(18989);
//此处我们使用的公网ip
addr.sin_addr.s_addr = inet_addr("118.89.67.215");
int ret = connect(sockfd,(struct sockaddr *)&addr,sizeof(addr));
if(ret < 0)
{
cout << "connnect failed" << endl;
return 0;
}
//3.接收和发送数据
while(1)
{
//这里接收数据和发送数据是没有顺序而言的
//因为已经经过三次握手,客户端和服务端的连接以及建立成功了
char buf[1024] = {0};
//发送数据
sprintf(buf,"hello server,i am client2\\n");
ssize_t send_ret = send(sockfd,buf,strlen(buf),0);
if(send_ret < 0)
{
cout << "send failed" << endl;
continue;
}
memset(buf,'\\0',sizeof(buf));
ssize_t recv_size = recv(sockfd,buf,sizeof(buf)-1,0);
if(recv_size < 0)
{
cout << "recv failed" << endl;
continue;
}
else if(recv_size == 0)
{
cout << "Peer close" << endl;
close(sockfd);
return 0;
}
cout << "recv data is : " << buf << endl;
}
close(sockfd);
return 0;
}
2.1 单进程版本的TCP代码
单进程版本的服务端代码如下:
#include <unistd.h>
#include <sys/socket.h>
#include <string.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <iostream>
using namespace std;
/*
* 1. 创建套接字
* 2. 绑定地址信息
* 3. 进行监听--(告诉OS内核可以接收客户端发送的新请求了并且监听新连接的到来)
* 4. TCP三次握手建立连接----OS会自动进行,不需要我们进行控制
* 5. 接收新连接
* 6. 接收数据、发送数据
* 7. 关闭套接字
*/
int main()
{
//1. 创建套接字
int sockfd = socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);
if(sockfd < 0)
{
cout << "TCP socket failed" << endl;
return 0;
}
//2.绑定地址信息
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(18989);
addr.sin_addr.s_addr = inet_addr("0.0.0.0");
int ret = bind(sockfd,(struct sockaddr *)&addr,sizeof(addr));
if(ret < 0)
{
cout << "bind failed" << endl;
return 0;
}
//3.进行侦听(告诉OS内核可以与客户端建立连接了)
//listen函数中blocklog参数,设定的是已完成三次握手并已建立连接的队列的容量
ret = listen(sockfd,1);
if(ret < 0)
{
cout << "listen failed" << endl;
return 0;
}
//4.接收新连接
struct sockaddr_in client_addr;
socklen_t socklen = sizeof(client_addr);
int new_sockfd = accept(sockfd,
(struct sockaddr *)&client_addr,&socklen);
if(new_sockfd < 0)
{
cout << "accept failed" << endl;
return 0;
}
printf("accept success: %s:%d, and new_sockfd is %d\\n",
inet_ntoa(client_addr.sin_addr),ntohs(client_addr.sin_port),
new_sockfd);
//5.接收和发送数据
while(1)
{
//这里接收数据和发送数据是没有顺序而言的
//因为已经经过三次握手,客户端和服务端的连接以及建立成功了
char buf[1024] = {0};
ssize_t recv_size = recv(new_sockfd,buf,sizeof(buf)-1,0);
if(recv_size < 0)
{
cout << "recv failed" << endl;
continue;
}
else if(recv_size == 0)
{
cout << "Peer close" << endl;
close(new_sockfd);
close(sockfd);
return 0;
}
cout << "accrpt data is : " << buf << endl;
//发送数据
memset(buf,'\\0',sizeof(buf));
sprintf(buf,"hello lient,i am server,i am %s:%d\\n",
inet_ntoa(addr.sin_addr),ntohs(addr.sin_port));
ssize_t send_ret = send(new_sockfd,buf,strlen(buf),0);
if(send_ret < 0)
{
cout << "send failed" << endl;
continue;
}
}
close(sockfd);
return 0;
}
分析完代码后,我们来看看客户端与服务端之间的通信吧!!
分别运行客户端和服务端所得到的结果如下:
服务端的结果:
客户端的结果:
看上去好像成功了,但是这只是一个客户端与服务器通信的情况,如果是多个客户端呢?他的情况是怎样的?在上面的基础上,我们再运行一个客户端,看看其结果如何:
我们发现第二个客户端完全不运行,这是什么情况呢?我们使用ps aux | grep []
加pstack []
命令来对其进行分析。
稍微对服务端代码进行分析就可知,因为服务端accept在while循环外面,所以只能从已完成连接队列中拿出一次连接进行通信,而第二个客户端进来后,虽然和服务端三次握手成功建立了连接,但是并没有被服务端接收其连接,因此当客户端代码走到recv逻辑的时候就会发生阻塞,因为客户端是不会给他回复任何消息的。
那能否将服务端accept代码放到while循环里面?答案肯定也是不行的,稍微分析一下,当while循环每次走到accept逻辑的时候,都要从已完成连接队列中获取连接,当队列为空的时候就会阻塞掉,因此,当有两个客户端的时候,它们之间只能通信一次,然后就会陷入到accept阻塞的逻辑中。
稍微总结一下,单进程版本的TCP代码当有多个客户端与其服务器进行通信的时候,依据代码逻辑的不同,可能会产生两种阻塞:recv阻塞和accept阻塞。
那么该如何解决呢?有三种解决办法。
- 多进程版本的TCP代码
- 多线程版本的TCP代码
- 使用多路转接的技术(这个在后面会进行讲解)
2.2 多进程版本的TCP代码
再写多进程代码的时候,一定要注意的点有:父进程只管accept,子进程只管send和recv数据,因为父子进程的进程虚拟空间是几乎相同的,父进程从已完成连接队列中得到的套接字,子进程也能拥有。
但是一定要注意到进程等待,如果子进程先于父进程退出,且父进程没有进行相应的进程等待,那么该子进程就会变成僵尸进程。但是如果我们直接在父进程的逻辑中加上wait函数是行不通的,因为,随着客户端的增多,父进程创建出的子进程也逐渐变多,wait函数会捕捉子进程退出时所发出的SIGCHLD信号,但是是不知道捕捉的是哪一个进程的。
因此,在这里,我们使用signal函数将SIGCHLD该信号处理重新用我们自己的逻辑定义一下即可。
服务端的代码如下:
#include <unistd.h>
#include <sys/wait.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <signal.h>
#include <netinet/in.h>
#include <string.h>
#include <iostream>
using namespace std;
void signalcallBack(int)
{
wait(NULL);
return;
}
int main()
{
signal(SIGCHLD,signalcallBack);
//创建套接字
int listen_sock = socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);
if(listen_sock < 0)
{
cout << "socket failed" << endl;
return 0;
}
//绑定地址信息
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(18989);
addr.sin_addr.s_addr = inet_addr("0.0.0.0");
int ret = bind(listen_sock,(struct sockaddr *)&addr,sizeof(addr));
if(ret < 0)
{
cout << "bind failed" << endl;
return 0;
}
//帧听
ret = listen(listen_sock,1);
if(ret < 0)
{
cout << "listen failed" << endl;
return 0;
}
while(1)
{
struct sockaddr_in peer_addr;
socklen_t peerlen;
int new_sockfd = accept(listen_sock,
(struct sockaddr *)&peer_addr,&peerlen);
if(new_sockfd < 0)
{
cout << "accept failed" << endl;
return 0;
}
pid_t fork_ret = fork();
if(fork_ret < 0)
{
cout << "fork failed" << endl;
continue;
}
else if(fork_ret == 0)
{
close(listen_sock);
//child
//在子进程中只实现接收和发送数据
while(1)
{
char buf[1024] = {0};
ssize_t recv_size = recv(new_sockfd,buf,sizeof(buf)-1,0);
if(recv_size < 0)
{
cout << "recv failed" << endl;
continue;
}
else if(recv_size == 0)
{
cout << "peer failed" << endl;
break;
}
cout << "i recv: " << buf << endl;
//接收数据
memset(buf,'\\0',sizeof(buf));
sprintf(buf,"hello client,%s:%d\\n",
inet_ntoa(peer_addr.sin_addr),
ntohs(peer_addr.sin_port));
ssize_t send_size = send(new_sockfd,buf,strlen(buf),0);
if(send_size < 0)
{
cout << "send failed" << endl;
break;
}
}
}
else
{
//father
//要进行进程等待,防止子进程变成僵尸进程
//但是还存在着的问题是,该父进程可能会创建多个子进程
//若是直接在父进程中进行进程等待的话,就意味着若只有一个子进程退出
//父进程也会随之等待到资源,然后continue上去会陷入到accept的逻辑中
//因此,我们需要设置信号,自定义信号的处理逻辑,每当有一个sigchild
//信号发送出来的时候,我们让其在自定义的处理函数进行进程等待即可
continue;
}
}
return 0;
}
可能是我上传的图片太多了,上传图片总是失败,所以这里就不再进行结果的验证了。
2.3 多线程版本的TCP代码
多线程和多进程一样,也需要考虑几点,要将新的套接字传入线程入口函数,为此我们要在堆上申请一个空间用来存储新的套接字;为了不让线程进行等待,我们可以在线程入口函数中直接将自己线程分离掉即可;最后一点,一定要注意退出时对内存进行释放,关闭套接字,避免内存泄漏。
我在这里用一个类将TCP的各个接口给封装了起来,换句话说,到时候可以在客户端或服务端直接调用封装的类的接
以上是关于Linux:TCP Socket编程(代码实战)的主要内容,如果未能解决你的问题,请参考以下文章
iOS Socket/Tcp编程 GCDAsyncSocket的实战(带回调)