socket函数、sockaddr_in结构体 和 bind函数
socket函数的作用是创建一个网络文件描述符,程序通过这个文件描述符将数据发送到网络,也通过这个文件描述符从网络中接受数据。观察一下socket函数:
int listenfd;
listenfd = socket(AF_INET, SOCK_STREAM, 0)
会发现这个函数有三个参数,其中前两个参数指定了底层协议族为AF_INET(进行本地域通信),传输层使用SOCK_STREAM(字节流协议),即TCP协议。
我们知道,在传输层中网络是通过套接字(ip,端口)来进行定位的,但是socket中并没有指定套接字。默认情况下,系统会会随意分配一个端口,使用本机的ip地址。
这在客户端是没有问题的,客户端可以选择任意的端口和服务器进行通信。但是,服务器不行,因为客户端是主动向服务器发送数据的,它需要知道数据应该发送到服务器的哪个端口。所以需要服务器事先指定好端口号,服务器通过这个端口向服务器发送数据,也从这个端口从客户端接收数据。
由此,需要一个套接字结构体sockaddr_in来定义套接字:
struct sockaddr_in servaddr; // 服务端地址信息的数据结构。
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET; // 协议族,在socket编程中只能是AF_INET。
servaddr.sin_addr.s_addr = htonl(INADDR_ANY); // 任意ip地址。
servaddr.sin_port = htons(atoi(argv[1])); // 指定通信端口。
在这个套接字中,除了ip和端口,也指定了协议族,该协议组应该和传递给socket函数的一致。这里INADDR_ANY表示本机的任意ip地址,因为有些服务器不止一块网卡,多网卡的情况下,用该参数表示所有网卡的ip地址。ip地址也可以通过servaddr.sin_port = htons(atoi(argv[1]))
的方式指定特定ip地址。
好了,现在有了套接字,但是和先前定义的socket文件描述符没有关系,所以还需要通过bind函数进行绑定:
bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr));
这个过程也叫socket命名。
上面的过程配置的是服务器的套接字,对于服务器来说,这是数据发送的起点,要将数据发送给客户端,还需要知道数据的终点,也就是客户端的套接字。客户端的套接字可以有accept函数获取。
listen监听队列、accept函数 及 数据的收发
当服务器得到socket文件描述符后,就可以准备和用户进行通信了。由于一般有多个客户端,所以服务器会通过一个监听队列来保存用户的连接请求。该监听队列通过listen函数来创建:
listen(listenfd, 5);
这里listen的是服务器的listenfd套接字,对客户端来说,这是数据的终点,所以服务器可以针对这个套接字进行监听。当有数据发送到这个套接字的时候,服务器将这个连接请求放入到listen监听队列。
当服务器要处理客户端连接请求时,通过accept函数选择一个连接。客户端发送的请求到达服务器后,服务器可以从报文的报头部分获得客户端的ip地址和端口号。
创建一个客户端套接字,服务器将这些信息保存到该套接字中,以便后续通信中将数据发送到客户端套接字。
int clientfd; // 客户端的socket。
int socklen = sizeof(struct sockaddr_in); // struct sockaddr_in的大小
struct sockaddr_in clientaddr; // 客户端的地址信息。
clientfd = accept(listenfd, (struct sockaddr *)&clientaddr, (socklen_t *)&socklen);
需要注意的是,accept是阻塞式的,当监听队列中没有请求连接时,accept将进入阻塞状态。
这就意味着,在传统的网络通信模型中,单线程的服务器将只能处理一个连接请求,只有当前的请求完成后,才能处理下一个请求,这是串行的处理方式。
梳理一下:服务器在处理客户端请求的过程中,将涉及到两个套接字,服务器通过监听本地的套接字来获取连接请求;之后通过accept获取客户端套接字,进行数据的收发,所以在进行数据收发时,只需要用到客户端套接字clientfd
:
char buffer[1024];
memset(buffer, 0, sizeof(buffer));
recv(clientfd, buffer, sizeof(buffer), 0); \\接收数据
strcpy(buffer, "ok");
send(clientfd, buffer, strlen(buffer), 0); \\发送数据
连接的释放
当客户端主动释放连接后,客户端和服务器之间将进行四次挥手,其中第四次挥手可以通过netstat -nt | grep port
观察到
比如下面的服务器程序,该程序的功能是接受客户端发送的信息,再将信息发送回去。
/*
* 程序名:server.cpp,此程序用于演示socket通信的服务端
* 作者:C语言技术网(www.freecplus.net) 日期:20190525
*/
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <stdlib.h>
#include <netdb.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
int main(int argc, char *argv[])
{
if (argc != 2)
{
printf("Using:./server port\nExample:./server 5005\n\n");
return -1;
}
// 第1步:创建服务端的socket,和文件描述符一样
int listenfd;
// 初始化时只指定了所用的底层协议族为AF_INET(本地域通信),传输层使用SOCK_STREAM(字节流协议),即TCP协议
if ((listenfd = socket(AF_INET, SOCK_STREAM, 0)) == -1)
{
perror("socket");
return -1;
}
// 第2步:把服务端用于通信的地址和端口绑定到socket上。
struct sockaddr_in servaddr; // 服务端地址信息的数据结构。
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET; // 协议族,在socket编程中只能是AF_INET。
servaddr.sin_addr.s_addr = htonl(INADDR_ANY); // 任意ip地址。
// servaddr.sin_addr.s_addr = inet_addr("192.168.190.134"); // 指定ip地址。
servaddr.sin_port = htons(atoi(argv[1])); // 指定通信端口。
if (bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) != 0)
{
perror("bind");
close(listenfd);
return -1;
}
// 第3步:把socket设置为监听模式。
if (listen(listenfd, 5) != 0) // listen函数创建一个listen监听队列用于存放用户连接!!!
{
perror("listen");
close(listenfd);
return -1;
}
printf("Fininsh listening, try to accept...\n");
// 第4步:接受客户端的连接。
int clientfd; // 客户端的socket。
int socklen = sizeof(struct sockaddr_in); // struct sockaddr_in的大小
struct sockaddr_in clientaddr; // 客户端的地址信息。
// accept从listen监听队列中取出一个用户连接,当监听队列为空时,accept陷入阻塞!!!
clientfd = accept(listenfd, (struct sockaddr *)&clientaddr, (socklen_t *)&socklen);
printf("客户端(%s)已连接。\n", inet_ntoa(clientaddr.sin_addr));
// 第5步:与客户端通信,接收客户端发过来的报文后,回复ok。
char buffer[1024];
while (1)
{
int iret;
memset(buffer, 0, sizeof(buffer));
if ((iret = recv(clientfd, buffer, sizeof(buffer), 0)) <= 0) // 接收客户端的请求报文。
{
printf("iret=%d, waitting next connection\n", iret);
break;
}
printf("接收:%s\n", buffer);
strcpy(buffer, "ok");
if ((iret = send(clientfd, buffer, strlen(buffer), 0)) <= 0) // 向客户端发送响应结果。
{
perror("send");
break;
}
printf("发送:%s\n", buffer);
}
// 第6步:关闭socket,释放资源。
close(listenfd);
close(clientfd);
}
首先通过gcc -o server ./server.c
编译得到可执行文件,再通过./server 1234
启动该服务程序后,可以通过telnet 127.0.0.1 1234
在本地建立和服务器(本机)的连接。
随后,通过netstat -nt | grep 1234
可以查看连接释放后端口的使用情况,如下:
tcp 0 0 127.0.0.1:1234 127.0.0.1:60026 TIME_WAIT
和1234有关的连接只有一个,这是客户端到服务器的连接,该连接处于TIME_WAIT状态。对照四次挥手示意图,可以看到客户端的这个连接将会持续2MSL。此时再次通过1234
端口来启动服务器将会出现端口占用的提示:
bind: Address already in use
简单分析一下,当客户端接受到FIN=1,ACK=1
后,客户端知道服务器要释放连接了,于是发送报文ACK=1
,在收到该报文之前服务器处于LAST_ACK
状态,并未释放连接资源。
如果网络不好,服务器可能收不到该报文,一直处于LAST_ACK
状态,并且把原因归咎为客户端没有收到FIN=1, ACK=1
报文,所以服务器会继续发送该报文。此时客户端等待的时间没有超过2MSL并处于TIME_WAIT
状态,接受到该报文后他明白服务器的连接还未释放,所以再次发送ACK=1
报文,通知服务器正常释放连接,避免服务器资源的消耗。
网络正常的情况下,当2MSL过去后,第四次挥手顺利完成,再次使用netstat -nt | grep 1234
查看会发现该连接自动释放掉了。