UNIX网络编程笔记(3)—基本TCP套接字编程

基本TCP套接字编程

主要介绍一个完整的TCP客户/服务器程序需要的基本套接字函数。

1.概述

在整个TCP客户/服务程序中,用到的函数就那么几个,其整体框图如下:


2.socket函数

为了执行网络I/O,一个进程必须要做的事情就是调用socket函数。其函数声明如下:

#include <sys/socket.h>
int socket(int family ,int type, int protocol);

其中:

family:指定协议族

type:指定套接字类型

protocol:指定某个协议,设为0,以选择所给定family和type组合的系统默认值。

这些参数有一些特定的常值定义如下:

faimly 说明
AF_INET IPv4协议
AF_INET6 IPv6协议
AF_LOCAL Unix域协议
AF_ROUTE 路由套接字
AF_KEY 密钥套接字

表1 socket函数的family常值


type 说明
SOCK_STREAM 字节流套接字
SOCK_DGRAM 数据报套接字
SOCK_SEQPACKET 有序分组套接字
SOCK_RAW 原始套接字

表2 socket函数的type常值


protocol 说明
IPPROTO_TCP TCP传输协议
IPPROTO_UDP UDP传输协议
IPPROTO_SCTP SCTP传输协议

表3 socket函数AF_INET或AF_INET6的protocol常值



socket函数调用成功的时候将返回一个小的非负整数值,成为套接字描述符,简称sockfd。为了得到这个描述符,我们制定了协议族和套接字类型,并未指定本地与远程协议地址。

另外,书中还提到一个AF_XXX(表示地址族)和PF_XXX(表示协议族)的区别,一般情况下都使用AF,知道这个就可以了。


3.connect函数

函数声明如下:

#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr *servaddr,socklen_t addrlen);

sockfd:由socket返回的套接字描述符。

servaddr:套接字地址结构,包含了服务器IP和端口号。

addrlen:套接字地址结构大小,防止读越界。

客户端调用connect时,将向服务器主动发起三路握手连接,直到连接建立和连接出错时才会返回,这里出错返回的可能有一下几种情况:

1)TCP客户没有收到SYN分节的响应。(内核发一个SYN若无响应则等待6s再发一个,若仍无响应则等待24s后再发送一个。总共等待75s仍未收到则返回错误ETIMEDOUT

2)若对客户的SYN的响应是RST,表明服务器主机在我们指定的端口上没有进程在等待与之连接,客户端收到RST就会返回ECONNREFUSED错误

产生RST的三个条件是:目的地SYN到达却没有监听的服务器;TCP想取消一个已有连接;TCP接收到一个根本不存在连接上的分节。

3)若客户发出的SYN在中间的某个路由器上引发了一个“destination unreachable”(目的地不可达)ICMP错误,则认为是一种软错误,在某个规定时间(比如上述75s)没有收到回应,内核则会把保存的信息作为EHOSTUNREACH或ENETUNREACH错误返回给进程。

若connect失败则该套接字不再可用,必须关闭,我们不能对这样的套接字再次调用connect函数,当循环调用函数connect为给定主机尝试各个ip地址直到有一个成功时,在每次connect失败后,都必须close当前的套接字描述符并从新调用socket。


4.bind函数

bind函数把一个本地协议地址赋予了一个套接字。协议地址时32位IPv4地址或128位的IPv6地址与16位的TCP/UDP端口号的组合。

在调用bind函数可以制定一个特定的端口号,或者制定一个IP地址,或者两个都指定,后者两者都不指定。

函数声明如下:

#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr * myaddr,socklen_t addrlen);

参数说明:

sockfd:套接字描述符

myaddr:套接字地址结构的指针

addrlen:上述结构的长度,防止内核越界

服务器在启动时捆绑它们的众所周知的端口,例如时间获取服务的端口13。如果不调用bind函数,当调用connect或listen的时候,TCP会创建一个临时的端口,这对于客户端来说很常见(毕竟我们从来没见过客户端程序调用过bind函数),而对于TCP服务器来说就比较少见了,因为TCP服务器就是通过其众所周知的端口被大家认识。

进程可以把一个特定的IP地址绑定到它的套接字上:对于客户端来说,这没有必要,因为内核将根据所外出网络接口来选择源IP地址。对于服务器来说,这将限定服务器只接收目的地为该IP地址的客户连接。

对于IPv4来说,通配地址由常值INADDR_ANY来指定,其值一般为0,它告知内核去选择IP地址,因此我们经常看到如下语句:

struct sockaddr_in servaddr;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);

同理,端口号指定为0时,内核就在bind被调用的时候选择一个临时端口,不过bind函数不返回内核选择的值,第二个参数有const限定。如果想要直到内核所选择的临时端口值,必须调用getsockname来返回协议地址。

最后需要注意的是:bind绑定保留端口号时需要超级用户权限。这就是为什么我们在linux下执行服务器程序的时候要加sudo,如果没有超级用户权限,绑定将会失败。


5.listen函数

listen函数由TCP服务器调用,其函数声明如下:

#include <sys/socket.h>
int listen (int sockdfd , int backlog);

listen函数主要有两个作用:

1.对于参数sockfd来说:当socket函数创建一个套接字时,它被假设为一个主动套接字。listen函数把该套接字转换成一个被动套接字,指示内核应接受指向该套接字的连接请求。

2.对于参数backlog:规定了内核应该为相应套接字排队的最大连接数=未完成连接队列+已完成连接队列

其中:

未完成连接队列:表示服务器接收到客户端的SYN但还未建立三路握手连接的套接字(SYN_RCVD状态)

已完成连接队列:表示已完成三路握手连接过程的套接字(ESTABLISHED状态)

结合三路握手的过程:

1.客户调用connect发送SYN分节

2.服务器收到SYN分节在未完成队列建立条目

3.直到三鹿握手的第三个分节(客户对服务器SYN的ACK)到达,此时该项目从未完成队列移动到已完成队列的队尾。

4.当进程调用accept时,已完成队列出队,当已完成队列为空时,accept函数阻塞,进程睡眠,直到已完成队列入队。

所以说,如果三路握手正常完成,未完成连接队列中的任何一项在其中存留的时间就是服务器在收到客户端的SYN和收到客户端的ACK这段时间(RTT)。

如图所示:

对于一个WEB服务器来说,RTT是187ms。

关于这两个队列还有需要注意的地方:当客户端发送SYN分节到达服务器时,如果此时服务器的未完成连接队列是满的,服务器将忽略这个SYN分节,服务器不会立即给客户端回应一个RST,因为客户端有自己的重传机制,如果服务器发送RST,那么客户度端的connect就会返回错误了。另外客户端无法区别RST究竟意味着“该端口没有服务器在监听”还是意味着“该端口有服务器在监听不过它的队列满了。”


6.accept函数

TCP服务器调用accept函数,函数声明如下:

#include<sys/socket.h>
int accept (int sockfd, struct sockaddr *cliaddr ,socklen_t * addrlen);

参数说明:

sockfd:套接字描述符

cliaddr:对端(客户)的协议地址

addr:大小

当accept调用成功,将返回一个新的套接字描述符,例如:

int connfd = Accept(listenfd,(SA*)NULL,NULL);

其中我们listenfd为监听套接字描述符,称connfd为已连接套接字描述符。,区分这两个套接字十分重要,一个服务器进程通常只需要一个监听套接字,但是却能够有很多已连接套接字(比如通过fork创建子进程),也就是说每有一个客户端建立连接时就会创建一个connectfd,当连接结束时,相应的已连接套接字就会被关闭。

通过指针我们可以得到客户端的套接字信息,但是如果我们对这些不感兴趣就可以另他们为NULL,书中给出一个示例,服务器相应连接后,打印客户端的IP地址和端口号。

部分代码如下:

#include    "unp.h"
#include    <time.h>
int
main(int argc, char **argv)
{
    //...
    for ( ; ; ) {
        len = sizeof(cliaddr);
        connfd = Accept(listenfd, (SA *) &cliaddr, &len);//已连接套接字
        //cliaddr获取客户端协议地址信息。
        printf("connection from %s, port %d\n",
               Inet_ntop(AF_INET, &cliaddr.sin_addr, buff, sizeof(buff)),
               ntohs(cliaddr.sin_port));

        //...
        Close(connfd);
    }
}

7.fork和exec函数

用fork创建子进程的方法并不陌生,这里将用fork编写并发服务器程序。

#include <unisted.h>
pid_t fork(void);

在子进程中返回0,在父进程返回返回子进程ID,因为有这个父子的概念,所以fork函数调用一次却要返回两次。

关于fork函数的一些特性:

1.任何子进程只能有一个父进程,并且子进程可以通过getppid获取父进程ID

2.父进程中调用fork之前打开的描述符,在fork之后与子进程分享,在网络通信中也正是应用到这个特性。

说到上述第2个特性,我们知道服务器进程往往在死循环中等待客户端连接,利用特性2,当accept函数调用返回一个connfd时,子进程可以利用其进行读写,而父进程直接关闭即可。

简而言之:父进程创建描述符,子进程对其实际操作。

exec函数实际上是6个函数,他们的区别主要在于:

(a)待执行的程序文件是由文件名还是路径名指定。

(b)新程序的参数是一一列出来还是由一个指针数组来引用

(c)把调用进程的环境传递给新程序还是给新程序指定新的环境。

关于EXEC的详情可参考:linux下c语言编程exec函数使用


8.并发服务器

首先一个概念叫做“迭代服务器”,例如:

for(;;)
{
    connfd = Accept(listenfd,(SA*)NULL,NULL);
    ticks=time(NULL);
    snprintf(buff,sizeof(buff),"%.24s\r\n",ctime(&ticks));
    Write(connfd,buff,strlen(buff));
    Close(connfd);
}

当一个客户端连接过来时,服务器向客户端写入时间信息后,关闭已连接套接字,回到for循环顶部阻塞等待连接的到来,这样每次连接到来的时候,必须完成该次服务,因为它占用了服务器进程。但是由于简单的获取时间服务本身就很快,单次服务马上就完成了,所以也就影响不大,不过如果是十分耗时的服务就不一定了,我们并不希望服务器被单个客户长时间占用,而是希望服务器同时服务多个用户,于是在Unix中编写并发服务器程序最简单的办法就是fork一个子进程来服务每个客户。

pid_t pid;
int listenfd;
int connfd;
listenfd=Socket(/*...*/);
Bind(listenfd,/*...*/);
Listen(listenfd,/*...*/);
for(;;)
{
    connfd = Accept(listenfd,(SA*)NULL,NULL);
    if((pid=fork())==0)//子进程
    {
        close(listenfd);//关闭监听套接字
        doit(connfd);//服务
        close(connfd);
        exit(0);
    }
    close(connfd);
}

这里我一直不理解的是,为什么在子进程里面要关闭监听套接字(listenfd)呢?

这就跟fork的相关知识有关:

1.首先fork并不是把父进程从头到尾执行一遍,否则这样不就无穷尽了。

2.父进程在调用fork处,整个父进程空间会原模原样地复制到子进程中,包括指令,变量值,程序调用栈,环境变量,缓冲区,等等。

3.在并发服务器的示例中,子进程会将已连接的套接字(connfd)和监听套接字(listenfd)拷贝到自己的进程空间。

4.对于套接字描述符来说,他们都有一个引用计数,fork之后由于描述符被复制,其引用计数都变成了2。

5.因此,我们在父进程中关闭connfd(因为我们在子进程中使用connfd),在子进程中关闭listenfd(因为我们在父进程中进行监听),此时他们的引用计数都变成了1。

6.然后,我们所期望的状态就是父进程中保留一个监听套接字继续等待客户端连接,在子进程中通过已连接的套接字对客户端请求进行服务。

7.最后在子进程中关闭connfd,或exit(0),使得connfd真正完成清理和资源释放。


9.close函数

close函数可以用来关闭套接字并终止TCP连接。

#include <unistd.h>
int close(int sockfd);

从上节的并发服务器可以看到,close函数是对套接字描述符的引用计数减1,也就是说,如果调用close后,引用计数不为0,将不会引起TCP的四分组连接终止序列,这正是父进程与子进程共享已连接套接字的并发服务器所期望的。不过如果我们确实想在TCP连接上发送一个FIN,那么调用shutdown函数。


10.getsockname和getpeername函数

#include <sys/socket.h>
int getsockname(int sockfd,struct sockaddr*localaddr,socklen_t *addrlen);
int getpeername(int sockfd,struct sockaddr*peeraddr,socklen_t *addrlen);
//若成功则为0,失败则为-1

这两个函数的作用:

1.首先我们知道在TCP客户端一般不使用bind函数,当connect返回后,getsockname可以返回客户端本地IP地址和本地端口号。

2.如果bind绑定了端口号0(内核选择),由于bind的参数是const型的,因此必须通过getsockname去得到内核赋予的本地端口号。

3.获取某个套接字的地址族

4.以通配IP地址bind的服务器上,accept成功返回之后,getsockname可以用于返回内核赋予该连接的本地IP地址。其中套接字描述符参数必须是已连接的套接字描述符。


11.总结

本章介绍了套接字编程的函数,客户端和服务器端都从socket开始,客户端随后调用connect,而服务器端先后调用bindlistenaccept,最后使用close来关闭描述符。

时间: 2024-10-13 18:51:18

UNIX网络编程笔记(3)—基本TCP套接字编程的相关文章

UNIX网络编程笔记(2)—套接字编程简介

套接字编程概述 说到网络编程一定都离不开套接字,以前用起来的时候大多靠记下来它的用法,这一次希望能理解一些更底层的东西,当然这些都是网络编程的基础- (1)套接字地址结构 大多说套接字函数都需要一个指向套接字地址结构的指针作为参数,每个协议族都定义它自己的套接字地址结构,这些结构都以sockadd_开头. IPV4套接字地址结构 IPv4套接字地址结构通常称为"网际套接字地址结构",以sockaddr_in命名,并定义在 /* Internet address. */ typedef

Unix网络编程之基本TCP套接字编程(上)

TCP客户/服务器实例 服务器程序 #include "unp.h" int main(int argc, char **argv) { int listenfd, connfd; pid_t childpid; socklen_t clilen; struct sockaddr_in cliaddr, servaddr; listenfd = Socket(AF_INET, SOCK_STREAM, 0); //1 bzero(&servaddr, sizeof(servad

【UNIX网络编程(二)】基本TCP套接字编程函数

基于TCP客户/服务器程序的套接字函数图如下: 执行网络I/O,一个进程必须做的第一件事就是调用socket函数,指定期望的通信协议类型. #include <sys/socket.h> int socket(int family, int type, int protocol);/*返回值:若成功则为非负描述符,若出错则为-1*/ socket函数成功时返回一个小的非负整数值,它与文件描述符类似,把它称为套接字描述符,简称sockfd.family参数指明协议族,被称为协议域.type参数指

【UNIX网络编程(四)】TCP套接字编程详细分析

引言: 套接字编程其实跟进程间通信有一定的相似性,可能也正因为此,stevens这位大神才会将套接字编程与进程间的通信都归为"网络编程",并分别写成了两本书<UNP1><UNP2>.TCP套接字编程是套接字编程中非常重要的一种,仔细分析,其实它的原理并不复杂.现在就以一个例子来详细分析TCP套接字编程. 一.示例要求: 本节中试着编写一个完成的TCP客户/服务器程序示例,并对它进行深入的探讨.该示例会用到绝大多数的基本函数,未用到但比较重要的函数会在后面的补充上

《网络编程》基于 TCP 套接字编程的分析

本节围绕着基于 TCP 套接字编程实现的客户端和服务器进行分析,首先给出一个简单的客户端和服务器模式的基于 TCP 套接字的编程实现,然后针对实现过程中所出现的问题逐步解决.有关基于 TCP 套接字的编程过程可参考文章<基本 TCP 套接字编程>.该编程实现的功能如下: (1)客户端从标准输入读取文本,并发送给服务器: (2)服务器从网络输入读取该文本,并回射给客户端: (3)客户端从网络读取由服务器回射的文本,并通过标准输出回显到终端: 简单实现流图如下:注:画图过程通信双方是单独的箭头,只

《网络编程》基本 TCP 套接字编程

在进行套接字编程之前必须熟悉其地址结构,有关套接字的地址结构可参考文章<套接字编程简介>.基于 TCP 的套接字编程的所有客户端和服务器端都是从调用socket 开始,它返回一个套接字描述符.客户端随后调用connect 函数,服务器端则调用 bind.listen 和accept 函数.套接字通常使用标准的close 函数关闭,但是也可以使用 shutdown 函数关闭套接字.下面针对套接字编程实现过程中所调用的函数进程分析.以下是基于 TCP 套接字编程的流程图: socket 函数 套接

Unix网络编程学习笔记之第4章 基于TCP套接字编程

1. socket函数 int socket(int family, int type,int protocol) 成返回一个套接字描述符.错误返回-1 其中family指定协议族,一般IPv4为AF_INET, IPv6为AF_INET6. 其中type指定套接字类型,字节流:SOCK_STREAM.   数据报:SOCK_DGRAM. 一般情况下通过family和type的组合都可以唯一确定一个套接字类型.所以一般我们就把protocol设为0就可以了. 有时在某些特殊情况下,family和

unix网络编程第四章----基于TCP套接字编程

为了执行网络I/O操作.进程必须做的第一件事情就是调用Socket函数.指定期待的通信协议 #include<sys/socket.h> int socket(int family,int type,int protocol); family表示协议族,比如AF_INET,type表示套接字类型, protocol一般设置为0 family: AF_INET ipv4协议 type: SOCK_STREAM 字节流套接字 SOCK_DGRAM 数据报套接字 SOCK_RAW 原始套接字 pro

UNP学习笔记(第四章 基本TCP套接字编程)

本章讲解编写一个完整的TCP客户/服务器程序所需要的基本套接字函数. socket函数 #include <sys/socket.h> int socket(int family,int type,int protocol); //返回:成功则为非负描述符,若出错则为-1 family参数指明协议族,它是如下某个常值 type参数指明套接字类型,它是如下某个常值 protocol参数为下面某个协议类型常值,或者设为0,以选择所给定family和type组合的系统默认值 下图展示了基本TCP客户