网络编程:TCP 网络编程
参考:盛延敏:网络编程实战
TCP
TCP,又被叫做字节流套接字(Stream Socket),UDP 也有一个类似的叫法, 数据报套接字(Datagram Socket),一般分别以“SOCK_STREAM”与“SOCK_DGRAM”分别来表示 TCP 和 UDP 套接字。
Datagram Sockets 有时称为“无连接的 sockets”(connectionless sockets)。
Stream sockets 是可靠的、双向连接的通讯串流。比如以“1-2-3”的顺序将字节流输出到套接字上,它们在另一端一定会以“1-2-3”的顺序抵达,而且不会出错。这是由 TCP(Transmission Control Protocol)协议完成的,TCP 通过诸如连接管理,拥塞控制,数据流与窗口管理,超时和重传等一系列精巧而详细的设计,提供了高质量的端到端的通信方式。
详细的连接过程:
1)服务器端
① 创建套接字
创建可用的套接字
int socket(int domain, int type, int protocol)
入参解释:
domain:指 PF_INET、PF_INET6 以及 PF_LOCAL 等,表示什么样的套接字。
AF_LOCAL:表示的是本地地址,对应的是 Unix 套接字,这种情况一般用于本地 socket 通信,很多情况下也可以写成 AF_UNIX、AF_FILE;
AF_INET:因特网使用的 IPv4 地址;
AF_INET6:因特网使用的 IPv6 地址。这里的 AF_ 表示的含义是 Address Family,但是很多情况下,我们也会看到以 PF_ 表示的宏,比如 PF_INET、PF_INET6 等,实际上 PF_ 的意思是 Protocol Family,也就是协议族的意思。使用 AF_xxx 这样的值来初始化 socket 地址,用 PF_xxx 这样的值来初始化 socket。可在 <sys/socket.h> 头文件中可以清晰地看到,这两个值本身就是一一对应的。
/* 各种地址族的宏定义 */
#define AF_UNSPEC PF_UNSPEC
#define AF_LOCAL PF_LOCAL
#define AF_UNIX PF_UNIX
#define AF_FILE PF_FILE
#define AF_INET PF_INET
#define AF_AX25 PF_AX25
#define AF_IPX PF_IPX
#define AF_APPLETALK PF_APPLETALK
#define AF_NETROM PF_NETROM
#define AF_BRIDGE PF_BRIDGE
#define AF_ATMPVC PF_ATMPVC
#define AF_X25 PF_X25
#define AF_INET6 PF_INET6
type:
- SOCK_STREAM: 表示的是字节流,对应 TCP;
- SOCK_DGRAM: 表示的是数据报,对应 UDP;
- SOCK_RAW: 表示的是原始套接字。
protocol:原本是用来指定通信协议的,但现在基本废弃。因为协议已经通过前面两个参数指定完成。protocol 目前一般写成 0 即可。
② bind绑定套接字与套接字地址
调用 bind 函数的方式如下:
bind(int fd, sockaddr * addr, socklen_t len)
bind 函数后面的第二个参数是通用地址格式sockaddr * addr。传入的参数可能是 IPv4、IPv6 或者本地套接字格式。
bind 函数会根据 len 字段判断传入的参数 addr 该怎么解析,len 字段表示的就是传入的地址长度,它是一个可变值。
如何设置统配地址?
对于 IPv4 的地址来说,使用 INADDR_ANY 来完成通配地址的设置;
对于 IPv6 的地址来说,使用 IN6ADDR_ANY 来完成通配地址的设置。
struct sockaddr_in name;
name.sin_addr.s_addr = htonl (INADDR_ANY); /* IPV4通配地址 */
端口设置呢?
如果把端口设置成 0,就相当于把端口的选择权交给操作系统内核来处理,操作系统内核会根据一定的算法选择一个空闲的端口,完成套接字的绑定。这在服务器端不常使用。
③ listen监听
初始化创建的套接字,可以认为是一个"主动"套接字,其目的是之后主动发起请求(通过调用 connect 函数)。通过 listen 函数,可以将原来的"主动"套接字转换为"被动"套接字,告诉操作系统内核:“我这个套接字是用来等待用户请求的。”当然,操作系统内核会为此做好接收用户请求的一切准备,比如完成连接队列。
listen函数原型:
int listen (int socketfd, int backlog)
参数详解:
第一个参数 socketdf 为套接字描述符,第二个参数 backlog,在 Linux 中表示已完成 (ESTABLISHED) 且未 accept 的队列大小,这个参数的大小决定了可以接收的并发数目。理论上,这个参数越大,并发数量也会越大。
④ accept响应
ccept 这个函数看成是操作系统内核和应用程序之间的桥梁。它的原型是:
int accept(int listensockfd, struct sockaddr *cliaddr, socklen_t *addrlen)
函数的第一个参数 listensockfd 是套接字,可以叫它为 listen 套接字,因为这就是前面通过 bind,listen 一系列操作而得到的套接字。
函数的返回值有两个部分,第一个部分 cliadd 是通过指针方式获取的客户端的地址,addrlen 告诉我们地址的大小,这可以理解成当我们拿起电话机时,看到了来电显示,知道了对方的号码;另一个部分是函数的返回值,这个返回值是一个全新的描述字,代表了与客户端的连接。
PS:一定要注意有两个套接字描述字,第一个是监听套接字描述字 listensockfd,它是作为输入参数存在的;第二个是返回的已连接套接字描述字
accept之后,便建立了连接,然后便可收、发数据。
2)客户端发起连接请求的过程
第一步还是和服务端一样,要建立一个套接字,方法和前面是一样的。
不一样的是客户端需要调用 connect 向服务端发起请求。
① connect连接
函数原型:
int connect(int sockfd, const struct sockaddr *servaddr, socklen_t addrlen)
函数的第一个参数 sockfd 是连接套接字,通过前面讲述的 socket 函数创建。第二个、第三个参数 servaddr 和 addrlen 分别代表指向套接字地址结构的指针和该结构的大小。套接字地址结构必须含有服务器的 IP 地址和端口号。
如果是 TCP 套接字,那么调用 connect 函数将激发 TCP 的三次握手过程,而且仅在连接建立成功或出错时才返回。其中出错返回可能有以下几种情况:
- 三次握手无法建立,客户端发出的 SYN 包没有任何响应,于是返回 TIMEOUT 错误。这种情况比较常见的原因是对应的服务端 IP 写错。
- 客户端收到了 RST(复位)回答,这时候客户端会立即返回 CONNECTION REFUSED 错误。这种情况比较常见于客户端发送连接请求时的请求端口写错,因为 RST 是 TCP 在发生错误时发送的一种 TCP 分节。产生 RST 的三个条件是:目的地为某端口的 SYN 到达,然而该端口上没有正在监听的服务器(如前所述);TCP 想取消一个已有连接;TCP 接收到一个根本不存在的连接上的分节。
- 客户发出的 SYN 包在网络上引起了"destination unreachable",即目的不可达的错误。这种情况比较常见的原因是客户端和服务器端路由不通。
connect 之后便可进行读、写操作。
TCP 三次握手
目前,使用的网络编程模型都是阻塞式的。所谓阻塞式,就是调用发起后不会直接返回,由操作系统内核处理之后才会返回。 相对的,还有一种叫做非阻塞式的。
三次握手解读
-
宏观过程:
服务器端通过 socket,bind 和 listen 完成了被动套接字的准备工作,被动的意思就是等着别人来连接,然后调用 accept,就会阻塞在这里,等待客户端的连接来临;客户端通过调用 socket 和 connect 函数之后,也会阻塞。接下来的事情是由操作系统内核完成的,更具体一点的说,是操作系统内核网络协议栈在工作。 -
微观过程
1、客户端的协议栈向服务器端发送了 SYN 包,并告诉服务器端当前发送序列号 j,客户端进入 SYNC_SENT 状态;
2、服务器端的协议栈收到这个包之后,和客户端进行 ACK 应答,应答的值为 j+1,表示对 SYN 包 j 的确认,同时服务器也发送一个 SYN 包,告诉客户端当前我的发送序列号为 k,服务器端进入 SYNC_RCVD 状态;
3、客户端协议栈收到 ACK 之后,使得应用程序从 connect 调用返回,表示客户端到服务器端的单向连接建立成功,客户端的状态为 ESTABLISHED,同时客户端协议栈也会对服务器端的 SYN 包进行应答,应答数据为 k+1;
4、应答包到达服务器端后,服务器端协议栈使得 accept 阻塞调用返回,这个时候服务器端到客户端的单向连接也建立成功,服务器端也进入 ESTABLISHED 状态。
读写数据
经过前面服务端socket/bind/listen/accept,客户端socket/connnect 等操作,客户端与服务端建立了连接,连接建立的根本目的是为了数据的收发。
发送数据
发送数据时常用的有三个函数,分别是 write、send 和 sendmsg。
ssize_t write (int socketfd, const void *buffer, size_t size)
ssize_t send (int socketfd, const void *buffer, size_t size, int flags)
ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags)
三个函数使用场景略有不同:
- 第一个函数是常见的文件写函数,如果把 socketfd 换成文件描述符,就是普通的文件写入。
- 如果想指定选项,发送带外数据,就需要使用第二个带 flag 的函数。所谓带外数据,是一种基于 TCP 协议的紧急数据,用于客户端 - 服务器在特定场景下的紧急处理。
- 如果想指定多重缓冲区传输数据,就需要使用第三个函数,以结构体 msghdr 的方式发送数据。
发送缓冲区
当 TCP 三次握手成功,TCP 连接成功建立后,操作系统内核会为每一个连接创建配套的基础设施,比如发送缓冲区。
发送缓冲区的大小可以通过套接字选项来改变,当我们的应用程序调用 write 函数时,实际所做的事情是把数据从应用程序中拷贝到操作系统内核的发送缓冲区中,并不一定是把数据通过套接字写出去。
大部分 UNIX 系统的做法是一直等到可以把应用程序数据完全放到操作系统内核的发送缓冲区中,再从系统调用中返回。
可理解为:
当 TCP 连接建立之后,系统内核就开始运作起来。可以把发送缓冲区想象成一条包裹流水线,有个聪明且忙碌的工人不断地从流水线上取出包裹(数据),这个工人会按照 TCP/IP 的语义,将取出的包裹(数据)封装成 TCP 的 MSS 包,以及 IP 的 MTU 包,最后走数据链路层将数据发送出去。这样我们的发送缓冲区就又空了一部分,于是又可以继续从应用程序搬一部分数据到发送缓冲区里,这样一直进行下去,到某一个时刻,应用程序的数据可以完全放置到发送缓冲区里。在这个时候,write 阻塞调用返回。注意返回的时刻,应用程序数据并没有全部被发送出去,发送缓冲区里还有部分数据,这部分数据会在稍后由操作系统内核通过网络发送出去。
读取数据
read 函数的原型如下:
ssize_t read (int socketfd, void *buffer, size_t size)
read 函数要求操作系统内核从套接字描述字 socketfd读取最多多少个字节(size),并将结果存储到 buffer 中。
返回值为实际读取的字节数目,也有一些特殊情况,如果返回值为 0,表示 EOF(end-of-file),这在网络中表示对端发送了 FIN 包,要处理断连的情况;如果返回值为 -1,表示出错。如果是非阻塞I/O情况略有不同。
TCP socket编程(缓冲区实验)
服务端:tcp_server.c
#include <stdio.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
size_t readn(int fd, void *buffer, size_t size) {
char *buffer_pointer = (char *)buffer;
int length = size;
while (length > 0) {
int result = read(fd, buffer_pointer, length);
if (result < 0) {
if (errno == EINTR)
continue; /* 考虑非阻塞的情况,这里需要再次调用read */
else
return (-1);
} else if (result == 0)
break; /* EOF(End of File)表示套接字关闭 */
length -= result;
buffer_pointer += result;
}
return (size - length); /* 返回的是实际读取的字节数*/
}
void read_data(int sockfd)
{
ssize_t n;
char buf[1024];
int time = 0;
for(;;)
{
fprintf(stdout,"block in read\n");
if((readn(sockfd,buf,1024))==0)
return ;
time++;
fprintf(stdout,"1K read for %d\n",time);
usleep(1000);
}
}
int main(int argc,char *argv[])
{
int listenfd,connfd;
socklen_t clilen;
struct sockaddr_in cliaddr,servaddr;
listenfd = socket(AF_INET, SOCK_STREAM, 0);
bzero(&servaddr,sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(12345);
//bind到本地地址,端口号12345
bind(listenfd, (struct sockaddr *)& servaddr, sizeof(servaddr));
//监听端口,backlog为1024
listen(listenfd,1024);
//循环处理用户请求
for(;;)
{
clilen = sizeof(cliaddr);
connfd = accept(listenfd,(struct sockaddr *)&cliaddr, &clilen);
read_data(connfd);//读取数据
close(connfd);//关闭连接套接字,主要不是监听套接字
}
}
客户端:tcpclient.c
#include <stdio.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#define MESSAGE_SIZE 102400
void send_data(int sockfd)
{
char *query;
query = (char *)malloc(MESSAGE_SIZE+1);
for(int i = 0; i < MESSAGE_SIZE; i++)
{
query[i] = 'a';
}
query[MESSAGE_SIZE] = "\0";
const char *cp;
cp = query;
size_t remaining = strlen(query);
while(remaining)
{
int n_writen = send(sockfd, cp, remaining, 0);
fprintf(stdout, "send into buffer %ld \n",n_writen);
if(n_writen <= 0)
{
printf("send faild...\n");
return ;
}
remaining -= n_writen;
cp += n_writen;
}
return ;
}
int main(int argc,char *argv[])
{
int sockfd;
struct sockaddr_in servaddr;
if(argc != 2)
{
printf("argc:%d\n",argc);
printf("usage:tcpclient <IPaddress> \n");
return -1;
}
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if( sockfd < 0 )
{
perror("socket");
exit(EXIT_FAILURE);
}
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(12345);
inet_pton(AF_INET,argv[1], &servaddr.sin_addr);
int connect_rt = connect(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr));
if(connect_rt < 0)
{
printf("connect failed \n");
return -1;
}
send_data(sockfd);
exit(0);
}
效果:
程序运行起来后,服务端不断地在打印出读取字节流的过程:
而,客户端直到最后才打印下面一句话,说明在此之前send函数一直都是阻塞的,也就是说阻塞式套接字最终发送的实际写入字节数和请求字节数是相等的。
强调:
发送成功仅仅表示的是数据被拷贝到了发送缓冲区中,并不意味着连接对端已经收到所有的数据。至于什么时候发送到对端的接收缓冲区,或者更进一步说,什么时候被对方应用程序缓冲所接收,对我们而言完全都是透明的。