套接字编程
套接字编程也叫Socket
编程。这个章节将总结和归纳Linux操作系统下如何利用系统接口进行网络编程。
网络字节序
之前有讲过字节序这个概念,不同的主机往往有着不同的数据存储协议,分为大端以及小端两种,但既然是网络通信,不同主机之间就必须要统一规定一个字节序来规定数据传输方式,这个就被称为网络字节序。好在系统中有一些系统接口ntohs
和htons
来帮助我们完成网络字节序和本机字节序之间的相互转换。
传输层协议
网络通信是两端通信,客户端与服务端。主动发起请求的是客户端,被动接受请求的一段是服务端。永远是客户端先向服务端发送数据。通信中数据需要经过层层封装,每一层都有典型协议,但是传输层有两个协议,TCP/UDP协议。
协议特点
TCP协议特点:传输控制协议,面向连接,可靠传输,提供字节流传输服务。
UFP协议特点:用户数据报协议,无连接,不可靠,面向数据报。
TCP为了保证可靠传输牺牲了性能,因此适用于文件/压缩包/程序的传输;UDP速度快但是不够安全可靠,因此多应用于视频在线观看的传输。
UDP网络通信编程
流程
1、创建套接字,是进程与网卡直接建立关联。在内核中会创建一个socket
结构体。在这个结构体中会包含很多与网络通信有关的信息。
2、为套接字绑定地址信息(ip/port)。为了告诉操作系统哪些数据应该由这个进程处理。在操作系统内核中每一个套接字都会有一块缓冲区,上面存放着这个套接字绑定的地址信息所属的进程应该接收的数据。网络通信过程中操作系统会把本机上所有进程需要接收数据统一放进套接字缓冲区后再发送给绑定的进程,同样的发送数据也是一样的原理,因此才需要跟操作系统内核中的套接字绑定地址信息来认领属于自己的那一块缓冲区。
3、客户端首先向服务端发送数据。服务端指定对端的地址,这时候socket
就会将数据从绑定的地址发送出去。通常服务端必须固定一个地址信息,不能随意改变,保证客户端能够连上固定的服务器。但是客户端的地址可以随意,因为数据先由客户端发送,发送给服务端,服务端就能获知客户端的地址。
4、服务端接收数据。客户端发送的数据道道服务端主机后,服务端操作系统根据这个数据的地址信息决定将这个数据放到哪一个套接字的缓冲区中。服务端通过创建套接字返回的描述符,在内核中找到套接字结构体,进而从缓冲区中取出数据。
5、关闭套接字,释放内核中套接字占用的资源。
接口
1 | //创建套接字 |
实现
这里首先完全使用C来完成服务端的功能,虽然没有经过封装流程过于复杂但是可以帮助我们更好的理解udp通讯的流程。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
57
58
59
60
61
62
63
64
65
66/**
* 传输层基于UDP协议的服务端程序
* 1、创建套接字
* 2、为套接字绑定地址信息
* 3、接收数据
* 4、发送数据
* 5、关闭套接字
**/
#include <stdio.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
int main(int argc, char* argv[])
{
if(argc != 3)
{
printf("Usage: ./main 192.168.122.132 9000\n");
return -1;
}
int sockfd = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
if(sockfd < 0)
{
perror("socket error\n");
return -1;
}
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(atoi(argv[2]));
//inet_addr将点分十进制ip地址转换为网络字节序ip地址
addr.sin_addr.s_addr = inet_addr(argv[1]);
socklen_t len = sizeof(struct sockaddr_in);
int ret = bind(sockfd, (struct sockaddr*)&addr, len);
if(ret < 0)
{
perror("bind error");
return -1;
}
while(1)
{
char buf[1024] = {0};
struct sockaddr_in cliaddr;
socklen_t len = sizeof(struct sockaddr_in);
int ret = recvfrom(sockfd, buf, 1023, 0, (struct sockaddr*)&cliaddr, &len);
if(ret < 0)
{
perror("recvfrom error");
close(sockfd);
return -1;
}
printf("client say: %s\n", buf);
memset(buf, 0, 1024);
scanf("%s", buf);
len = sizeof(struct sockaddr_in);
ret = sendto(sockfd, buf, strlen(buf), 0, (struct sockaddr*)&cliaddr, len);
if(ret < 0)
{
perror("sendto error");
close(sockfd);
return -1;
}
}
close(sockfd);
}
接下来使用Cpp对udp通讯再进行一次封装,这样可以方便我们之后的使用,使用也会更有模块化,之后用其实现客户端,这里可以选择把客户端地址信息写死,或者不绑定系统自己分配都可以。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
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118/**
* 封装实现一个udpsocjet类,向外提供更加容易使用的udp接口
**/
#include <iostream>
#include <string>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <unistd.h>
#include <stdlib.h>
#define CHECK_RET(q) if((q) == false){return -1;}
using std::string;
class UdpSocket
{
public:
UdpSocket()
:_sockfd(-1)
{
}
~UdpSocket()
{
Close();
}
//创建套接字
bool Socket()
{
_sockfd = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
if(_sockfd < 0)
{
std::cerr << "socket error" << std::endl;
return false;
}
return true;
}
//绑定本机ip地址及端口信息
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)
{
std::cerr << "bind error" << std::endl;
return false;
}
return true;
}
//接收数据并接收对端的ip地址及端口信息
bool Recv(string& buf, string& ip, uint16_t& port)
{
char tmp[4096];
struct sockaddr_in peeraddr;
socklen_t len = sizeof(peeraddr);
int ret = recvfrom(_sockfd, tmp, 4096, 0, (struct sockaddr*)&peeraddr, &len);
if(ret < 0)
{
std::cerr << "recvfrom error" << std::endl;
return false;
}
buf.assign(tmp, ret);
port = ntohs(peeraddr.sin_port);
ip = inet_ntoa(peeraddr.sin_addr);
return true;
}
//发送数据
bool Send(const string& data, const string& ip, const 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 = sendto(_sockfd, &data[0], data.size(), 0, (struct sockaddr*)&addr, len);
if(ret < 0)
{
std::cerr << "send error" << std::endl;
return false;
}
return true;
}
bool Close()
{
if(_sockfd >= 0)
{
close(_sockfd);
_sockfd = -1;
}
return true;
}
private:
int _sockfd;
};
int main(int argc, char* argv[])
{
if(argc != 3)
{
std::cerr << "./udp_cli serverip serverport" << std::endl;
return -1;
}
UdpSocket sock;
CHECK_RET(sock.Socket());
//CHECK_RET(sock.Bind("192.168.11.128", 8000));
while(1)
{
string buf;
std::cin >> buf;
CHECK_RET(sock.Send(buf, argv[1], atoi(argv[2])));
buf.clear();
string ip;
uint16_t port;
CHECK_RET(sock.Recv(buf, ip, port));
std::cout << "server say: " << buf << std::endl;
}
}
然后我们把他们都跑起来完成通讯。1
2
3
4
5
6
7
8
9
10
11
12
13
14(客户端发送数据)
[misaki@localhost Net]$ ./client 192.168.11.128 9000
nihao
(服务端接收数据)
[misaki@localhost Net]$ ./server 192.168.11.128 9000
client say: nihao
(服务端回复)
[misaki@localhost Net]$ ./server 192.168.11.128 9000
client say: nihao
nihao
(客户端收到回复)
[misaki@localhost Net]$ ./client 192.168.11.128 9000
nihao
server say: nihao
TCP网络通信编程
流程
TCP建立连接比UDP更为复杂一些,因为为了保证安全必须点对点一对一进行建立连接,于是TCP建立连接就产生了所谓的三次握手建立连接。
关于三次握手建立连接的解读这里给上一篇文章,这篇文章解读清晰易懂,有理有据,提供参考。
https://baijiahao.baidu.com/s?id=1614404084382122793&wfr=spider&for=pc
大概来说只有三次握手才能避免丢包延迟等情况造成的连接无效,才能完全确认连接已经建立。为了安全建立连接这是udp所没有的。
站在应用层面,我们服务端为了接收客户端的连接请求,需要有以下步骤:
1、建立套接字。
2、绑定地址信息。
3、服务端开始监听。
4、关闭每一个连接套接字,以及皮条套接字。
但是这里要注意tcp的套接字与udp不同一个套接字只能与一台客户端建立连接,而不是一个套接字即可接收所有发往本机的所有数据。一但一个套接字与一个主机已经建立连接,它的状态就会改变为已建立连接状态将无法再监听其他客户端的连接请求。那么此时其他客户端想要通讯我们的套接字还在与上一个主机通信呢其他客户端就都会无法连接到服务端,这该如何处理呢?
这里tcp在处理时利用了一种特殊的机制,这种机制十分类似于拉皮条。没错就是拉皮条,我们最开始建立的套接字只是一个监听套接字,这个套接字就是拉皮条的,我们称之为皮条套接字,开始监听后,只要有客户端想要与这个服务端建立连接,我们的皮条套接字就会自己创建一个新的套接字与客户端进行绑定,并将新产生的套接字返回给我们,我们就可以利用这个新的套接字与指定的客户端进行通信。意思是说我们的皮条套接字并不实质与服务端通信,它只负责创建新的套接字为客户端提供一对一服务。
当我们的服务端开始监听后,我们皮条套接字就开始工作了,表示客户端此时可以进来进行通信了,并且还是一对一服务哦,但是建立连接也是需要时间的,三次握手嘛,并且一对一通信也需要时间,如果此时客户端不断向服务端发送连接请求,每一个请求都会创建新的套接字,这会消耗大量资源,在高峰期可能资源就会耗尽。为了防止这样的情况发生,tcp在监听时会创建一个队列,我们称之为未完成连接队列,我们的皮条套接字会为这个队列依次创建套接字进行连接,如果这个队列满了,客户端此时就不能再连接服务端了,皮条套接字也不用再创建新的套接字了,着手完成眼下的套接字连接以及通信,以此来控制资源。至于这个队列有多大,我们可以在监听时来指定这个队列的大小。
4、获取新创建的套接字描述符进行通信。在创建连接后,我们得从皮条套接字那里获取新的建立连接的套接字才能进行通信。
对于客户端来说,为了和服务端建立连接,也要有以下这些步骤:
1、创建套接字。
2、绑定地址信息。
3、向服务端发起三次握手建立连接,这里需要给入服务端的地址信息。
4、接收发送数据。这里的接收和发送数据不需要再想udp呢样每次都必须给如详细的对端地址信息了,这里已经有套接字建立了稳定连接,只需要传入指定的套接字描述符即可。
5、关闭套接字。
从流程可以看出,udp和tcp在建立套接字和绑定地址信息上没有太大区别,区别主要在tcp在进行数据传输前要先建立一次连接,连接建立完成后使用套接字描述符即可进行数据传输。
接口
创建套接字与绑定地址信息的接口以及关闭套接字都与udp一致。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//服务端监听
int listen(int sockfd, int backlog);
//sockfd:皮条套接字
//backlog:未完成连接队列大小,并发连接数
//返回值:成功返回0,失败返回-1
//客户端连接服务端
int connect(int sockfd, sockaddr* srvaddr, socklen_t addrlen);
//sockfd:服务端套接字描述符
//srvaddr:服务端地址信息
//addrlen:服务端地址信息大小
//返回值:成功返回0,失败返回-1
//服务端接收客户端连接
int accept(int sockfd, sockaddr* srcaddr, socklen_t* len);
//sockfd:皮条套接字描述符
//srcaddr:客户端地址信息
//len:客户端地址信息长度
//返回值:返回皮条套接字新建立的套接字描述符,失败返回-1
//接收数据
int recv(int sockfd, void* buf, int len, int flags);
//sockfd:套接字描述符
//buf:接收缓冲区
//len:接收数据长度
//flags:默认0阻塞接收
//返回值:实际接收字节数,失败返回-1, 连接断开返回0
//发送数据
int send(int sockfd, void* buf, int len, int flags);
//sockfd:套接字描述符
//buf:发送缓冲区
//len:发送数据长度
//flags:默认0阻塞发送
//返回值:实际发送的字节数,失败返回-1
实现
1 | /** |
这里是对tcp的接口进行一次封装,接下来实现客户端与服务端。
客户端: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#include "tcp_socket.hpp"
#include <stdlib.h>
/**
* 实现客户端
* 1、创建套接字
* 2、绑定地址信息(客户端不需要手动绑定)
* 3、向服务端发起连接请求
* 4、发送数据
* 5、接收数据
* 6、关闭套接字
**/
int main(int argc, char* argv[])
{
if(argc != 3)
{
std::cerr << "./tcp_cli srvip srvport" << std::endl;
return -1;
}
TcpSocket sock;
CHECK_RET(sock.Socket());
std::string ip = argv[1];
uint16_t port = atoi(argv[2]);
CHECK_RET(sock.Connect(ip, port));
while(1)
{
std::string buf;
std::cin >> buf;
bool ret = sock.Send(buf);
if(ret == false)
{
sock.Close();
return -1;
}
buf.clear();
ret = sock.Recv(buf);
if(ret == false)
{
sock.Close();
return -1;
}
std::cout << "server say: " << buf << std::endl;
}
sock.Close();
}
服务端: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/**
* 服务端实现
* 1、创建套接字
* 2、绑定地址信息
* 3、开始监听
* 4、获取新连接
* 5、接收数据
* 6、发送数据
* 7、关闭套接字
**/
#include "tcp_socket.hpp"
#include <stdlib.h>
int main(int argc, char* argv[])
{
if(argc != 3)
{
std::cerr << "./tcp_srv 192.169.11.128 9000" << std::endl;
return -1;
}
std::string ip = argv[1];
uint16_t port = atoi(argv[2]);
TcpSocket sock;
CHECK_RET(sock.Socket());
CHECK_RET(sock.Bind(ip, port));
CHECK_RET(sock.Listen());
//这个新的套接字要放在循环外部,否则一次循环结束变量销毁会关闭套接字连接就会断开
TcpSocket newsock;
while(1)
{
bool ret = sock.Accept(newsock);
if(ret == false)
{
continue;
}
std::string buf;
ret = newsock.Recv(buf);
if(ret == false)
{
std::cerr << "recv error" << std::endl;
newsock.Close();
continue;
}
std::cout << "client say: " << buf << std::endl;
buf.clear();
std::cin >> buf;
newsock.Send(buf);
}
sock.Close();
}
使用:1
2
3
4
5
6
7
8[misaki@localhost Net]$ ./tcpserver 192.168.11.128 9000
client say: nihao
wobuhao
[misaki@localhost Net]$ ./tcpclient 192.168.11.128 9000
nihao
server say: wobuhao
haha
但是在这里发现进行完一轮通信后,服务端无法再接收到客户端新的数据,这是因为此时监听套接字(我们还是叫的好听点)和通信套接字是在同一个进程中共同工作,此时监听套接字阻塞在了监听新的客户端,已经建立好的套接字就无法继续通信。为了解决这个问题我们必须使监听套接字和通信套接字共同同时工作,因此就牵扯到了并行的问题,这里有两种解决方案,利用多进程,或者多线程。
客户端代码是不用改变的,此时要改的只有服务端的代码。
1、多进程解决。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
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90/**
* 服务端实现
* 1、创建套接字
* 2、绑定地址信息
* 3、开始监听
* 4、获取新连接
* 5、接收数据
* 6、发送数据
* 7、关闭套接字
**/
#include "tcp_socket.hpp"
#include <stdlib.h>
#include <sys/wait.h>
#include <signal.h>
void sigcb(int signo)
{
//等待任意一个进程退出
//SIGCHLD信号是一个非可靠信号
//多个进程同时退出有可能会造成事件丢失,导致有可能有僵尸进程没有被处理
//因此在一次事件回调中,将能够处理的僵尸进程全都处理掉
while(waitpid(-1, NULL, WNOHANG) > 0);
}
int main(int argc, char* argv[])
{
if(argc != 3)
{
std::cerr << "./tcp_srv 192.169.11.128 9000" << std::endl;
return -1;
}
//在这里进行信号改写
signal(SIGCHLD, sigcb);
std::string ip = argv[1];
uint16_t port = atoi(argv[2]);
TcpSocket sock;
CHECK_RET(sock.Socket());
CHECK_RET(sock.Bind(ip, port));
CHECK_RET(sock.Listen());
//这个新的套接字要放在循环外部,否则一次循环结束变量销毁会关闭套接字连接就会断开
TcpSocket newsock;
//这里要并行执行,这里使用多进程
//让主进程继续获得新连接获取
//子进程负责与客户端通信
//并且这种处理方式更加稳定,子进程出现问题主进程并不会出现问题
while(1)
{
bool ret = sock.Accept(newsock);
if(ret == false)
{
continue;
}
//子进程处理与客户端的数据通信
if(fork() == 0)
{
//这里让子继承再创建一个子进程,本身直接退出,结束父进程的等待
/*
if(fork() > 0)
{
exit(0);
}
*/
//这里处理数据通信的实际上是子进程的子进程,但是子进程已经推出了
//这个孙子进程会变成孤儿进程,归init进程管理,并且退出不会变成僵尸进程
while(1)
{
std::string buf;
ret = newsock.Recv(buf);
if(ret == false)
{
std::cerr << "recv error" << std::endl;
newsock.Close();
exit(0);
}
std::cout << "client say: " << buf << std::endl;
buf.clear();
std::cin >> buf;
newsock.Send(buf);
}
newsock.Close();
exit(0);
}
//父进程直接关闭新连接的套接字
newsock.Close();
//父进程要等待子进程,防止变成僵尸进程,但是子进程如果一直不结束,父进程会造成阻塞
//因此这里有两种方式处理
//1、让子进程再创建孙子进程处理数据通信,子进程直接退出,孙子进程会变成孤儿进程不会变成僵尸进程
//wait(NULL);
//2、在子进程退出后会向父进程发送信号,信号会一直等着我们进行处理,因此我们可以通过改写这个信号来回收所有子进程
}
sock.Close();
}
使用:1
2
3
4
5
6
7
8
9
10
11[misaki@localhost Net]$ ./tcpserver 192.168.11.128 9000
client say: nihao
wohenhao
client say: haode
heihei
[misaki@localhost Net]$ ./tcpclient 192.168.11.128 9000
nihao
server say: wohenhao
haode
server say: heihei
这样就能进行多轮通信。
2、多线程解决。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
57
58
59
60
61
62
63
64
65
66
67/**
* 服务端实现
* 1、创建套接字
* 2、绑定地址信息
* 3、开始监听
* 4、获取新连接
* 5、接收数据
* 6、发送数据
* 7、关闭套接字
**/
#include "tcp_socket.hpp"
#include <stdlib.h>
#include <pthread.h>
//线程入口函数,创建线程进行数据通信
void* thr_start(void* arg)
{
TcpSocket* newsock = (TcpSocket*)arg;
while(1)
{
std::string buf;
bool ret = newsock->Recv(buf);
if(ret == false)
{
std::cerr << "recv error" << std::endl;
newsock->Close();
return NULL;
}
std::cout << "client say: " << buf << std::endl;
buf.clear();
std::cin >> buf;
newsock->Send(buf);
}
newsock->Close();
delete newsock;
return NULL;
}
int main(int argc, char* argv[])
{
if(argc != 3)
{
std::cerr << "./tcp_srv 192.169.11.128 9000" << std::endl;
return -1;
}
std::string ip = argv[1];
uint16_t port = atoi(argv[2]);
TcpSocket sock;
CHECK_RET(sock.Socket());
CHECK_RET(sock.Bind(ip, port));
CHECK_RET(sock.Listen());
while(1)
{
//这个要分配内存在堆区才能达到数据共享
TcpSocket* newsock = new TcpSocket();
bool ret = sock.Accept(*newsock);
if(ret == false)
{
//连接失败别忘了也要把空间销毁,以免内存泄露
delete newsock;
continue;
}
pthread_t tid;
//创建线程
pthread_create(&tid, NULL, thr_start, (void*)newsock);
pthread_detach(tid);
}
sock.Close();
}
使用: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[misaki@localhost Net]$ ./tcpserver 192.168.11.128 9000
client say: nihao
haode
client say: heihei
wowotou
client say: yikuaiqian
sige
client say: heihei
client say: nihao
heihei
client say: haha
houhu^Hou
client say: haha
heihei
peer shutdown
recv error
chuwentile
client say: dengdeng
en
client say: shia
en
[misaki@localhost Net]$ ./tcpclient 192.168.11.128 9000
nihao
server say: haode
heihei
server say: wowotou
yikuaiqian
server say: sige
heihei
server say: heihei
haha
[misaki@localhost Net]$ ./tcpclient 192.168.11.128 9000
nihao
server say: houhou
haha
dengdeng
server say: chuwentile
shia
server say: en
server say: en
这里起了两个客户端都是可以直接进行通信的,但是要注意一点有时候线程分离线程没有及时关闭情况下我们的数据有可能还会给已经关闭了的服务端发过去。