Unix网络函数与TCP状态转变之间的关系

connect函数导致状态转变

client状态变化:

connect函数导致当前client套接字从CLOSED(初始状态)转移到SYN_SENT状态,若成功则再转移到ESTIBLISHED状态,若失败,则回到CLOSED状态

server TCP状态变化:

当client发送SYN分节,server接收成功并返回SYN分节之后,server套接字将从LISTEN状态转移到SYN_RCVD状态,server发送SYN分节之后,client返回ACK到server,Server套接字状态从SYN_RCVD状态转变为ESTIBLISHED状态。

注意:若connect失败,必须调用close函数将当前socket关闭,不可再次调用connect函数。若需重试,则关闭后重新创建socket进行connect操作

listen函数导致状态转变

listen函数把一个未连接的套接字转换成一个被动套接字,调用listen导致套接字从CLOSED状态转变为LISTENED状态

Comment and share

Unix网络编程相关错误码和信号处理

特别注意当出现一下错误码时,处理之后一定要将errno复位为0

在网络编程的过程中会出现一些错误码,下面总结:

  • EAGAIN(11) : Resource temporarily unavailable

    • 错误原因:当将套接字设置为异步时,由于函数调用之后是立即返回的,所以会出现两种情况导致这个错误:(1):当调用read函数,此时没有数据可读,此时read函数会立即返回错误码EAGAIN表示此时无数据可读(2):当调用write函数,此时缓冲区满,write函数将会立即返回错误码EAGAIN。
    • 解决方法:EAGAIN错误表示此时无数据可读或者缓冲区已满,所以此时只需要重试即可。
  • ECONNRESET(104):Connection reset by peer

    • 错误原因:当对端socket已关闭,此时调用read或write函数将返回ECONNRESET错误,在之后如果继续调用read或write,就会得到该错误。常见的原因是发送端接收端实现约定好的数据长度不一致,若接收端被通知需要接收99个字节,而服务端发送了100个字节给接收端,这样一来,接收端接收99个字节就执行了close操作,如果发送端继续发送,接收端将向发送端返回一个RESET信号
  • EALREADY (114):Operation already in progress

    • 错误原因:套接字为非阻塞套接字,并且原来的链接请求还未完成
  • EINPROGRESS(115):Operation in progress

    • 错误原因:套接字为非阻塞套接字,连接正在建立

网络编程相关信号

  • SIGPIPE(13):管道破裂。管道另一端没有进程接收数据,导致管道破裂而崩溃。对一个对端已经关闭的socket进行两次write,第二次调用将会产生该信号,此信号的默认行为是结束进程
    • 解决方法:将该信号的处理函数设置为SIG_IGN,即忽略此信号

Comment and share

Unix中select,poll,epoll详解

网络应用需要处理的问题无非两类,网络I/O和数据计算问题。
在处理计算密集型任务的时候,期间会有一些网络IO操作(如写数据库的操作,非本机),此时若使用同步IO,则会造成大量的IO等待,造成CPU使用率较低。所以此时考虑其他IO模型如异步模型。

Unix下网络I/O模型包括五类:

  • 阻塞式IO
  • 非阻塞式IO
  • 多路复用IO
  • 信号驱动IO(边缘触发)
  • 异步IO

其中多路复用I/O机制是通过select,poll以及epoll进行监视。这里暂时只介绍多路复用IO,若想了解其他IO模型,参考《Unix网络编程》第六章

多路复用I/O模型

网络I/O的本质是socket的读取,socket在linux系统中被抽象为流,所以I/O操作可以理解为对流的操作。这个操作包括两个阶段:

  • 等待流数据准备就绪(wait for data be ready)
  • 从内核相进程复制数据

由于非阻塞调用的过程中,轮训占据了大部分的过程,所以轮训会占据大量的CPU时间。如果轮训不是进程的用户态,而是有人帮忙就好了。多路复用正好处理这样的问题。

多路复用的过程:多路复用有两个特别的系统调用select和poll。select调用是内核级别的,select轮训相对于非阻塞的轮训区别在于:前者可以等待多个socket,当其中一个socket数据准备好了,就能返回进行可读,然后进程再进行recvform系统调用,将数据由内核拷贝到进程中,拷贝的过程是阻塞的。

多路复用有两种阻塞,select或poll调用之后,会阻塞进程,与第一种阻塞不同在于,此时的select不是等到socket数据全部到达再处理, 而是有了一部分数据就会调用用户进程来处理。如何知道有一部分数据到达了呢?监视的事情交给了内核,内核负责数据到达的处理。也可以理解为”非阻塞”吧。

类比钓鱼过程:在钓鱼的时候,我们雇了一个帮手,他可以同时抛下多个鱼竿,任何一个鱼竿的鱼一上钩,他就会拉杆。他只负责帮我们钓鱼,并不处理,所以我们在一旁等着,等他收杆之后,我们再进行处理。

多路复用既然可以处理多个IO,也就带来了新的问题:多个IO的顺序不能保证

多路复用的特点多路复用通过一种机制一个进程能同时等待多个IO文件描述符,内核监视这些文件描述符(socket描述符),其中任意一个进入读就绪状态时,select,poll.epoll函数就可以返回。对于监视的方式,有可以分为select,poll,和epoll三种方式。

select函数详解

### 函数原型

1
2
3
#include <select.h>
#include <time.h>
int select(int maxfdp1, fd_set* readset, fd_set* writeset, fd_set* except_set, const struct timeval* timeout);

函数功能

该函数允许进程指示内核等待多个事件中的其中一个发生,并只在有一个或多个事件发生或者经历了一段时间之后才唤醒它。

其中等待的事件类型包括三种:指定集合中的描述符处于可读状态,执行集合中的描述符处于可写状态,指定集合中的描述符有异常未处理。

描述符就绪的条件如下:

可读就绪

当描述符满足下列四个条件中的其中一个,表示该描述符已经准备好读

  • 该套接字接收缓冲区的字节数大于等于套接字接收缓冲区的低水位标记的大小。一般对于TCP和UDP该值默认为1,我们也可以通过SO_RCVLOWAT套接字选项设置该套接字的低水位标记。
  • 该连接读半部关闭(接受了FIN的TCP连接),此时函数返回0。
  • 该套接字是一个监听套接字且完成的连接数不为0。对于这种套接字,accept通常不会阻塞。
  • 其上有一个套接字错误待处理.对这种套接字的读操作将不阻塞病返回-1。

可写就绪

当描述符满足下列四个条件中的其中一个,表示该描述符已经准备好写

  • 该套接字的发送缓冲区的可用空间字节大于等于套接字发送缓冲区的低水位标记大小。TCP和UDP的默认大小一般为2048。可以使用SO_SNDLOWAT套接字选项来设置该套接字的低水位标记
  • 该链接的写半部关闭
  • 使用非阻塞式connect的套接字建立连接,或者connect以失败告终
  • 其上有一个套接字错误未处理

函数参数

  • maxfdp1 : fd_set中最大的描述符+1(特别注意不要忘了+1),如readset中有{1,2,4},writeset中有{5,7,9},except_set中有{2,3,6,10},则此时的maxfdp1为11
  • readset : 需要监听的满足可读条件的描述符集合
  • writeset : 需要监听的满足可写条件的描述符集合
  • except_set : 需要监听的满足异常的描述符集合
  • timeout : 等待的时间,若超过此时间,函数返回

timeout的三种情况

  • timeout=NULL,等待时间无限长,即不限等待时间
  • timeout->sec=0,timeout->usec=0。此时不等待,函数立即返回
  • timeout->sec!=0 || timeout->usec != 0。此时为等待时间

函数返回值

  • 当监视的相应的文件描述符集合中存在满足条件的描述符时,比如说读文件描述符集中有数据到来时,内核IO根据状态修改文件描述符集,并返回一个大于0的数
  • 当没有满足条件的描述符且设置的timeval超时时,select函数返回0
  • 出错返回负数

若存在满足条件的描述符时,内核会将满足条件的描述符置位,并将其他描述符清0.这时,我们可以通过FD_ISSET来判断当前描述符是否满足条件.
如:
假设set为8位表示,起始为0000 0000。此时将{1,2,5}设置到读文件描述符集合中,即:

1
2
3
FD_SET(1, &readset);
FD_SET(2, &readset);
FD_SET(3, &readset);

置位以后set的位为:0000 0111

当调用select函数,并文件描述符2准备就绪时,此时select函数返回大于0的值,set的值变为:0000 0010。此时使用FD_ISSET可以检测到文件描述符2已经就绪。

fd_set相关操作

1
2
3
4
void FD_ZERO(fd_set* set); //将fd_set清空,一般声明fd_set第一步现先将其清空
void FD_SET(int fd, fd_set* set); //将某个fd置位
void FD_CLR(int fd, fd_set* set); //清空某个fd
int FD_ISSET(int fd, fd_set* set); //判断fd是否在set中

select函数底层实现原理

select底层实现的大致原理是,通过轮训文件描述符集中的文件描述符,检查描述符是否达到条件,若达到符合的相关条件则返回,否则轮训,但是当轮训的机制虽然是死循环,但是不是一直轮训,当内核轮训一遍文件描述符之后,会调用schedule_timeout函数挂起,等待fd设备或定时器来唤醒自己,然后再继续循环体看看哪些fd可用,以此提高效率。

若要了解详细的select实现原理参考如下博客:
http://janfan.cn/chinese/2015/01/05/select-poll-impl-inside-the-kernel.html
http://zhangyafeikimi.iteye.com/blog/248815

select函数的特点

select和poll为水平触发,epoll即支持水平触发也支持边缘触发。

缺点:

  • 最大并发数限制:从上面可以看出,被监听的描述符集合的大小受fe_set大小的限制,所以select监听的描述符的个数是有限制的,一般默认个数为1024或4096个等。
  • 效率问题:从select的底层实现可以看出,select每次调用都会线性扫描全部的fd集合,这样效率会出现线性下降,当把FD_SETSIZE增大可能会出现超时.
  • 内核用户空间拷贝问题:从select实现源码中不难看出,描述符集合以及timeout参数都是通过内存拷贝的方式从用户空间拷贝到了内核空间,也是会影响函数的性能。

poll函数详解

函数原型

1
2
3
4
5
6
7
8
9
10
#include <poll.h>
#include <time.h>
struct pollfd{
int fd; //file descriptor
short events; //被监听的事件状态(即select中监听当前描述符是否可写或者可读或者异常等等)
shor revents; //函数返回时该文件描述符的状态
};
int poll(struct pollfd* fds, unsigned long nfds, int timeout);

函数功能

poll的函数功能与select功能基本类似。但是poll函数可监听的文件描述符的个数基本没有限制,poll管理多个文件描述符的方式与select一致,都是轮训,并且都是讲文件描述符数组从用户空间复制到内核空间。

函数参数

  • fds : 被监听的描述符的数组
  • nfds : 数组中描述符的个数,这个大小足以监听linux所有的文件描述的符
  • timeout : 等待的时间

events的合法事件:

1
2
3
4
5
6
7
8
POLLIN --- 有数据可读(普通或优先级带数据)等价于POLLRDNORM||POLLRDBAND
POLLRDNORM --- 有普通数据可读
POLLRDBAND --- 有优先级带数据可读
POLLPRI --- 有高优先级数据可读
POLLOUT --- 有数据可写(普通数据)等价于POLLWRNORM
POLLWRNORM --- 有普通数据可写
POLLWRBBAND --- 有优先级带数据可写

初此之外,revents返回的事件还有:

1
2
3
POLLER --- 发生错误
POLLHUP --- 发生挂起
POLLNVAL --- 指定的描述符非法,没有打开

POLLIN | POLLPRI等价于select的读事件

POLLOUT | POLLWRBBAND 等价于select的写事件

函数返回值

  • 若监听的描述符满足条件,返回revents域不为0的文件描述符的个数
  • 若没有描述符满足条件且已过超时时间,poll返回0
  • 出错返回-1,并设置errno为以下值:
1
2
3
4
5
EBADF --- 一个或多个结构体中的描述符无效
EFAULTfds --- 指针指向的地址超出进程的地址空间
EINTR --- 请求的时间之前产生一个信号,调用可以重新发起
EINVALnfds --- 参数超出PLIMIT_NOFILE的值
ENOMEM --- 可用内存不足,无法完成请求

poll函数优缺点

优点

  • poll函数不需要计算最大的文件描述符加1.
  • poll函数监听的文件描述符的个数不受限制
  • poll相对于select函数应付大数目的描述符的效率较高。

缺点:

  • poll函数没有解决select轮训所有文件描述符的问题
  • poll函数和select相同都是将文件描述符信息从用户空间拷贝到内核空间。

epoll函数详解

函数原型

epoll相关数据结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//epoll_data保存触发事件相关的数据。(数据类型与具体使用方式有关)
typedef union epoll_data{
void* ptr;
int fd;
_uint32_t u32;
_uint64_t u64;
} epoll_data_t;
//保存感兴趣的事件和被触发的事件
struct epoll_event{
_uint32_t events;
epoll_data_t data;
};

其中events是一个美剧类型的集合,我们可以使用”|”来增加感兴趣的事件。枚举类型的值包括下面:

  • EPOLLIN : 表示关联的fd可以进行读操作
  • EPOLLOUT :表示关联的fd可以进行写操作
  • EPOLLRDHUP(2.6.17之后):表示套接字关闭了连接,或关闭了正写的一半的连接
  • EPOLLPRI : 表示关联的fd有紧急优先事件可以进行读操作。
  • EPOLLERR : 表示关联的fd发生了错误,epoll_wait会一直等待这个事件,所以一般没有必要设置这个属性
  • EPOLLHUP : 表示关联的fd被挂起,epoll_wait会一直等待这个事件,所以一般没有必要设置这个属性
  • EPOLLET : 设置关联的fd为ET的工作方式,即边缘触发
  • EPOLLONESHOT : 设置关联的fd为one-shot工作方式,表示只监听一次事件,如果要再次监听,需要把socket放入到epoll队列中。

epoll相关的函数有三个。

1
2
3
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event* event);
int epoll_wait(int epfd, struct epoll_event* events, int maxevents, int timeout);
  • epoll_create : 创建一个epoll句柄,注意创建epoll句柄会占用一个文件描述符,在使用完之后需要关闭。否则可能会导致文件描述符耗尽。
    • size : size为最大的监听文件描述符数,监听的文件描述符的个数不能超过size可以手动指定,但是这个数值可以达到系统可以开的最大的文件描述符数。
  • epoll_ctl : epoll的事件注册函数,它不同于select的是,它不是在监听事件的时候告诉内核要监听什么类型的时间,而是先注册要监听的事件类型。
    • epfd : epoll文件描述符,即epoll_ create的返回值,表示该epoll描述符注册事件
    • op : 注册事件的类型包括以下三类。
      • EPOLL_CTL_ADD : 注册行的fd到epfd中
      • EPOLL_CTL_MOD : 修改已经注册的fd的事件类型
      • EPOLL_CTL_DEL : 删除已经注册的fd
    • fd : 注册的文件描述符
    • event : 注册的时间的类型,告诉内核需要监听什么事件,类型包括上面几种。
  • epoll_wait : 收集epoll监控的时间中已经就绪的事件,若调用成功,返回就绪的文件描述符的个数,返回0表示超时。
    • epfd : epoll的文件描述符
    • events : 已经就绪的事件集合.内核不分配内存,需要程序自己分配内存传给内核,内核只负责将书复制到这里
    • maxevents : events数组的大小。
    • timeout : 超时时间。

水平触发(LT)与边缘触发(ET)

epoll的默认工作模式是水平触发(LT)。NGINX使用的epoll的ET工作模式

水平触发(level_triggered):当被监控的文件描述符上有可读可写事件的时,epoll_wait()会通知处理程序去读写。如果程序没有一次性把缓冲区的数据读完或者写完,那么下次调用epoll_wait的时候,他还会通知你该文件描述符仍可读写,如果你一直不去读写,它会一直通知你。如果系统中有大量你不需要读写的文件描述符,而他们每次都返回,这样会大大降低处理程序检索自己关心的就绪文件描述符的效率。

边缘触发(edge-triggered):当被监控的文件描述符上有读写事件发生时,epoll_wait会通知处理程序去读写,如果数据没有一次性读写完,那么下次你再调用epoll_wait的时候,它不会通知你,只有等到下一次发生读写事件的时候,它才会通知你。这种模式比水平触发的效率高,系统不会充斥大量你不关心的文件描述符。

注意:epoll工作在ET模式的时候,必须使用非阻塞的套接字,以避免由于一个文件句柄的阻塞读/阻塞写把多个文件描述符的任务饿死。最好以下面两种方式调用epoll接口

  • 基于非阻塞文件句柄
  • 只有当read/write返回值为EAGAIN时才需要挂起。但这不是说每次都需要循环读,直到读到产生EAGAIN才结束,只要读取到的长度小于预期的长度就说明缓冲区的数据我们已经读完了。

epoll族函数底层实现

epoll的使用方法上面已经有详细的描述,借口也简单易用。首先我们通过epoll_create创建一个epoll文件描述符,然后在epoll文件描述符上注册需要监听的事件,最后使用epoll_wait等待准备就绪的文件描述符。然而在每一步的过程中,内核都做了哪些操作?底层的实现方式是怎么样的?

内核使用了slab机制,为epoll提供了高效快速的数据结构。在内核中,epoll向内核注册了一个文件系统,用于存储被监控的文件描述符的信息。epoll在被内核初始化的时候(操作系统启动),同时会开辟出epoll自己的告诉cache区,用于安置我们需要监控的fd信息,这些fd信息会以红黑树的结构保存在内核cache中,以支持快速的查找,插入删除操作。这个内核高速cache区,就是建立连续的物理内存页,然后在之上建立slab层,简单的说,就是物理上分配好你想要的size的内存对象,每次使用时都是使用空闲的已分配好的对象。

epoll fd在内核中对应的数据够如下:

1
2
3
4
5
6
7
8
9
struct eventpoll {
spin_lock_t lock; //对本数据结构的访问
struct mutex mtx; //防止使用时被删除
wait_queue_head_t wq; //sys_epoll_wait() 使用的等待队列
wait_queue_head_t poll_wait; //file->poll()使用的等待队列
struct list_head rdllist; //事件满足条件的链表
struct rb_root rbr; //用于管理所有fd的红黑树(树根)
struct epitem *ovflist; //将事件到达的fd进行链接起来发送至用户空间
}

当向系统中添加一个fd时,就创建一个epitem结构体,这是内核管理epoll的基本数据结构:

1
2
3
4
5
6
7
8
9
10
11
12
struct epitem {
struct rb_node rbn; //用于主结构管理的红黑树
struct list_head rdllink; //事件就绪队列
struct epitem *next; //用于主结构体中的链表
struct epoll_filefd ffd; //这个结构体对应的被监听的文件描述符信息
int nwait; //poll操作中事件的个数
struct list_head pwqlist; //双向链表,保存着被监视文件的等待队列,功能类似于select/poll中的poll_table
struct eventpoll *ep; //该项属于哪个主结构体(多个epitm从属于一个eventpoll)
struct list_head fllink; //双向链表,用来链接被监视的文件描述符对应的struct file。因为file里有f_ep_link,用来保存所有监视这个文件的epoll节点
struct epoll_event event; //注册的感兴趣的事件,也就是用户空间的epoll_event
}
  1. 当调用epoll_create的时候,会首先在epoll内存中为分配一个eventpoll的内存大小,以保存当前的epoll描述符(epfd)结构,然后在这块内存上打开一个epoll文件。
  2. 当调用epoll_ctl的时候,如果增加fd(socket),则检查在红黑树中是否存在,存在立即返回,不存在则添加到红黑树上,然后向内核注册回调函数,用于当中断事件来临时向准备就绪list链表中插入数据。当我们调用epoll_ctl往里塞入百万个fd时,epoll_wait仍然可以飞快的返回,并有效的将发生事件的fd给我们用户。这是由于我们在调用epoll_create时,内核除了帮我们在epoll文件系统里建了个file结点,在内核cache里建了个红黑树用于存储以后epoll_ctl传来的fd外,还会再建立一个list链表,用于存储准备就绪的事件。
  3. 调用epoll_wait的时候立即返回准备就绪链表中的数据即可。

如此,一颗红黑树,一张准备就绪fd链表,少量的内核cache,就帮我们解决了大并发下的fd(socket)处理问题。

如果需要了解更详细的epoll底层实现,参考一下链接:
http://www.cnblogs.com/apprentice89/p/3234677.html

epoll特点

  • 没有文件描述符个数限制
  • 使用注册监听时间的方式,无需每次wait时都将时间从用户空间拷贝到内核空间,节省了内存拷贝的时间。
  • 使用回调机制,无需轮训所有的文件描述符检查状态。
  • 返回值只有准备就绪的文件描述符,检查准备就绪的文件描述符也不需要轮训。

Comment and share

Unix网络编程之socket异步编程

ioctl

我们常用ioctlsocket的FIONBIO模式对socket进行设置是否启用异步。

1
int ioctlsocket(int sockfd, long cmd, unsigned long* args);

ioctlsocket函数的作用是获取与套接字sockfd相关的操作参数,可用于任何状态的任一套接字,与具体的协议无关.

参数说明

  • sockfd : 待操作的socket描述符
  • cmd : 对sockfd的做操类型,包括以下几种
    • FIONBIO : 允许或者禁止套接字sockfd的费阻塞模式,若args为1,则是允许非阻塞模式;若args为0,禁止非阻塞模式。
    • FIONREAD : 确定套接字sockfd自动读入设为数量,arg存储ioctlsocket的返回值.若sockfd是SOCKET_STREAM类型,则FIONREAD返回一次recv所接受的所有的数据量,这通常与套接字中排队的数据总量相同。如果sockfd是SOC_DGRAM类型,则FIONREAD返回套接字上排队的第一个数据报的大小。
    • SIOCATMARK : 确定是否所有的带外数据都已经被读入,这个类型只适用于SOCK_STREAM套接字接口。
  • args : 指示参数

Comment and share

Unix网络编程之读写相关函数

socket编程中常用的输入输出函数(读写函数)总共有五对,它们都是默认阻塞的,这就意味着当发出一个不能立即完成的套接字调用的时候,其进程将被投入睡眠,等待相应的进程操作完成,函数才返回继续往下执行。常用的函数如下:

  • read, write
  • readv, writev
  • recv, send
  • recvfrom, sendto
  • recvmsg, sendmsg

read/write函数

read和write函数的详细用法已经在Unix文件IO中已经讲解,在socket编程中的使用方法与文件IO使用方法相同。

Comment and share

Unix网络编程之基础函数(一)

TCP客户端与服务器之间交互过程在程序实现上的体现如下图所示,注意思考这个过程中TCP状态的变化(参考TCP状态转换图):

服务器首先调用socket函数创建socket,通过bind函数将socket与主机地址及端口绑定,然后调用listen函数对主机端口进行监听,然后调用accept函数接收客户端发来的请求,若没有客户端请求,服务端将阻塞在此。客户端建立socket,然后调用connect函数与服务器三次握手建立连接,发送请求给服务器,服务器接收到请求之后对其进行处理并将处理结果返回给客户端,这个过程一直持续下去,一直到客户端与服务器断开连接,服务器接下来将关闭连接或继续等待下一个客户端的连接。

socket函数

为了执行网络IO,无论是客户端还是服务器,一个进程首先要做的事情就是调用socket函数创建一个socket,并指定socket的协议族,类型以及协议。

函数原型

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

参数说明

  • family : 协议族,对应于sockaddr_in中的sin_family,IPv4协议时值为AF_INET.
  • type : 套接字类型,套接字类型有多种,TCP一般使用SOCK_STREAM,UDP使用SOCK_DGRAM
    • SOCK_STREAM : 字节流套接字,是一种有序可靠双向的面向连接字节流的套接字
    • SOCK_DGRAM : 数据报套接字,是一种长度固定,无连接的不可靠的套接字。
    • SOCK_SEQPACKET : 有序分组套接字,是一种长度固定,有序,可靠的面向连接的有序分组套接字。
    • SOCK_RAW : 原始套接字
  • protocal : 链接层传输协议,包括TCP,UDP,SCTP传输协议
    • IPPROTO_TCP : TCP传输协议
    • IPPROTO_UDP : UDP传输协议
    • IPPROTO_SCTP : SCTP传输协议

一般情况下,我们在使用socket函数的时候,可以只指定前两个参数,将第三个参数设为0,这个时候socket会将protocol设置为默认值,如当family=AF_INET,type=SOCK_STREAM时,这个时候默认的协议为IPv4 TCP协议。但是,注意有些组合是不可用的,组合如下:

AF_TNET AF_INET6 AF_LOCAL AF_ROUTE AF_KEY
SOCK_STREAM TCP/SCTP TCP/SCTP
SOCK_DGRAM UDP UDP
SOCK_SEQPACKET SCTP SCTP
SOCK_RAW IPv4 IPv6

返回值说明

socket函数与open函数类似,若成功,返回一个较小的非负整数,称为套接字描述符,若失败返回-1;

connect函数

TCP客户端通过connect函数来与服务器建立连接,注意客户在调用connect函数之前不必调用bind函数将sockfd与本机的IP端口绑定,因为如果需要的话,内核会确定源IP地址,并选择一个可用的临时端口作为端口号

客户端调用connect函数的时候会激发三次握手连接建立。而且只在连接建立成功或失败时返回,否则会一直阻塞在connect函数处。

connect函数出错的情况包括以下几种:

  • 若TCP在调用connect之后没有接收到syn分节,则返回ETIMEOUT错误。这种情况通常发生在目的主机不存在情况下。这时客户端会隔一段时间发送一次请求,若等待时间超过一定时间(这个过程客户端被阻塞),返回ETIMEOUT错误。
  • 若T客户端收到RST分节(复位),则返回ECONNREFUSED错误,这种情况发生在目的主机存在,但是主机上没有进程监听指定端口。
  • 若客户端发送的分节在某个路由器上返回destination unreachable,此时返回EHOSTUNREACH,这种情况发生在目的主机和端口存在,但是中间路由出现问题

从TCP状态图可以看出,connect函数可以使得socket状态从closed转移到SYN_SENT状态,若成功,转换到ESTABLISHED状态。若套接字失败不可用,必须关闭。然后重新调用socket函数。

函数原型

1
int connect(int sockfd, const struct sockaddr* servaddr, socklen_t addrlen);

参数说明

  • sockfd : 此参数为调用sock函数的返回值,即套接字描述符
  • servaddr : 指向服务器套接字地址结构的指针,套接字地址结构中必须含有Ip地址以及端口。
  • addrlen : 上述套接字的长度。

返回值说明

连接失败,返回-1,并将错误码写入errno中;
若连接成功,则返回0.

bind函数

在建立socket之后,我们通常会将socket与一个套接字绑定,即将一个协议地址赋予给socket,bind函数的作用就是将socket与socket地址绑定。socket协议地址是32位的IPv4地址或128位的IPv6和16位的TCP或UDP端口的组合。

一般情况下,客户端一般不调用bind函数进行绑定socket地址(可调用),当其调用connect函数的时候,内核会获取主机的IP作为源IP,并选择一个可用的端口作为源端口,当然,客户端可以调用bind为socket指定源端口和IP;服务端一般要调用bind端口,为socket指定监听的端口和IP,因为一般情况下服务器是对外提供服务的,如http的80端口,https的443端口等等。

注意:socket绑定的端口必须是进程所在主机上的网络接口之一,不能是其他主机的IP,否则会出现“Cannot assign requested address”,也不能绑定已经在使用的端口。

函数原型

1
2
3
#include <sys/socket.h>
//函数参数与connect函数中的参数及意义相同。
int bind(int sockfd, const struct sockaddr* servaddr, socklen_t addrlen);

bind函数可以只绑定IP,只绑定端口,也可以两者都绑定,也可以两者都不绑定,具体的制定规则如下:

IP地址 端口 说明
统配地址(INAAR_ANY) 0 内核选择IP地址和端口
统配地址(INADDR_ANY) 非0 内核选择IP地址,进程指定地址
本地IP地址 0 进程指定IP,内核选择端口
本地IP地址 0 进程指定IP和端口

一般情况下,INADDR_ANY的值为0,将其转化为s_addr的时候使用htonl函数(将主机字节序转化为网络字节序),若是IPv6则不能使用htonl,因为IPv6的地址是一个128位的地址。需要使用另一种方式,具体参考《Unix网络编程卷一第三版》83页。

若设置为内核选定端口,则必须调用getsockname函数获取系统选定的端口。
从bind函数返回的一个常见的错误是:EADDRINUSE : address already in use;

listen函数

函数原型

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

listen函数仅由TCP服务器调用,一般在调用socket和bind函数之后调用 .当进程调用socket函数创建套接字之后,这个套接字被默认假设为主动套接字,(主动套接字的意思是将会调用connect去和TCP服务器建立连接)。listen函数主要有以下两个作用:

  • 将一个未连接的套接字转化为主动套接字,指示内核应该接收指向该套接字的连接请求。调用listen成功之后TCP从CLOSED状态转变为LISTEN状态。
  • 指定内核应该为相应套接字排队的最大连接数。即backlog指定,下面两个队列的和不能超过backlog.若查过backlog还有连接请求,服务器将直接发送RST复位拒绝连接。一般不要吧backlog值设为0,因为不同系统实现对0的解释不同;一般讲backlog的值设为5,因为这是4.2BSD支持的最大值。

内核为任何一个给定的监听套接字维护两个队列:

  • 未完成连接队列 :当服务器收到客户端发来的请求之后,数据还没有处理完,此时会进入SYN_RECV状态,这个时候将进入未完成连接队列,也叫SYN队列
  • 已完成连接队列:服务器与客户端之间建立连接之后,进入ESTABLISHED状态,此时进入已完成连接队列,也叫accept队列。

accept函数

accept函数的作用是从已经完成连接的队列头返回下一个已经完成的连接,如果已完成的连接为空,则进程进入睡眠状态(阻塞)。

函数原型

1
2
#include <sys/socket.h>
int accept(int servsockfd, struct sockaddr* cliaddr, socklen_t* addr_len);

参数说明

  • servsockfd : 监听的套接字描述符
  • cliaddr : 客户端的sockaddr,如果对客户端的来源不感兴趣,可以将其设置为NULL;
  • addr_len : aliaddr的字节数。即客户端sockaddr的地址长度。

返回值说明

若出错返回-1;如果accept成功,返回一个新的socket描述符。其代表与所返回的客户端之间的TCP连接,它是一个连接套接字,之后服务端接收和发送数据都将使用这个描述符进行操作.

Invalid argument常见原因

在accept的时候,我们常会遇到Invalid argument的错误,出现这个错误的原因有很多,主要是在accept之前的准备工作出了问题。

  • socket创建不成功,在socket函数后检查一下errno值
  • 绑定socket地址不成功,检查一下返回值,同时检查一下errno
  • 没有listen,或者listen出问题。

以上三步有一步出问题或者漏掉一步都有可能出现Invalid argument错误。

close函数

我们知道,关闭文件描述符我可使用close函数,同样的close函数也能用来关闭socket文件描述符。

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

通常情况下,close一个TCP套接字只是将该socket描述符标记为不可用,这个时候其将不能作为read和write的第一个参数进行数据的发送和接收,然而TCP尝试发送已经排队等待的所有需要发送到对端的数据,发送完毕之后将执行正常的TCP连接终止流程关闭TCP连接.

Comment and share

Unix网络编程之主机字节序与网络字节序

在各种计算机体系中,对于字节,字等的存储机制有所不同,但是在网络通信过程中,如果双方交流的信息存储结构不一致,则会导致通信失败的结果。当前计算机中通常采用的字节存储机制主要有两种:大端规则与小端规则网络通信的过程中的存储机制统一为大端规则。

字节序

参考:http://www.cppblog.com/tx7do/archive/2015/12/14/71276.html

Comment and share

Unix网络编程基础之套接字结构

大多数的套接字函数都使用到了套接字地址,它们以套接字地址的指针作为参数。每个协议族都定义了自己的套接字地址结构,这些套接字地址结构均以sockaddr_开头,以协议族唯一的后缀结尾。

IPv4套接字地址结构

IPv4的套接字以sockaddr_in命名,其具体定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <netinet/in.h>
struct in_addr {
in_addr_t s_addr;
};
struct sockaddr_in {
uint8 sin_len; //套接字的长度,sizeof(struct sockaddr_in)
sa_family_t sin_family; //协议族
in_port_t sin_port; //套接字端口
struct in_addr sin_addr; //套接字地址
char sin_zero[8]; //保留位
}

  • sin_len : 套接字的长度字段,类型为uint8_t,sizeof(struct sockaddr_in),不是所有的系统都支持。长度字段简化了可变长度的套接字的处理。在使用过程中无需设置和检查它,除非涉及路由套接字。
  • sin_family : 协议族,IPv4的协议族为AF_INET.类型为无符号整形,其长度受系统的影响。若sockaddr_in中含有sin_len字段,其大小为16位,若含有长度字段,其大小为8位。
  • sin_port : 套接字端口, 一般为uint16_t类型。
  • sin_addr : 套接字Ip,其类型为in_addr,in_addr中的s_addr类型为uint32_t.
  • sin_zero : 不常用,若需要在套接字中加入额外字段,使用到此字段,若不使用将其置为0,一般使用sockaddr_in首先将整个结构置0

注意:套接字地址结构仅在本机上使用,虽然结构中的某些字段用在不同主机之间的通信,但是结构体本身不在主机之间传递。

协议族参数说明

网络通信过程中有不同的协议族,通常我们在socket地址的sin_family中指出当前通信使用的协议族,不同协议族对应不同的参数,其对应参数如下所示:

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

通用套接字地址结构

套接字函数以套接字地址结构指针作为参数的过程中,由于在C中没有继承的机制,这个时候向套接字函数传递参数的时候,由于不同协议的套接字地址不同,会出现问题。这个时候有一种解决办法就是传递void*指针给socket函数,但是void空指针的出现在socket函数之后,所以这个方案不可行。这个时候的解决方案是 定义一个通用的套接字函数,socket函数的参数为通用套接字地址的指针,传递参数的时候我们将特定的套接字指针强制转换为通用套接字地址指针。 如bind函数的函数原型为:

1
int bind(int, struct sockaddr*, socklen_t);

通用套接字地址的定义如下:

1
2
3
4
5
struct sockaddr {
uint8_t sa_len;
sa_family_t sa_family;
char sa_data[14];
};

Ubuntu16.04中sockaddr_in的定义

Ubuntu16.04中sockaddr_in的定义在/usr/include/netinet/in.h,注意其不支持sin_len字段,为了保持与通用套接字字符串兼容,其保留字符串的长度直接用通用套接字的大小减去其他字段。

1
2
3
4
5
6
7
8
9
10
11
12
struct sockaddr_in
{
__SOCKADDR_COMMON (sin_);
in_port_t sin_port; // Port number.
struct in_addr sin_addr; // Internet address.
//Pad to size of struct sockaddr
unsigned char sin_zero[sizeof (struct sockaddr) -
__SOCKADDR_COMMON_SIZE -
sizeof (in_port_t) -
sizeof (struct in_addr)];
};

Comment and share

TCP状态转换图

TCP涉及连接建立和连接终止的操作可以用状态转换图来说明。

TCP为一个连接定义了11中状态,并且规定了如何基于当前状态以及该状态下接收的分节(TCP报文段)从一个状态转换为另一个状态。其状态转换图如下所示:

具体转化过程和转换条件如上图所示。

Comment and share

TCP的连接与建立

最常用的传输层协议包括TCP和UDP两种,当然除此之外还有其他协议。UDP是一种既不面向连接有不可靠的传输层协议,而TCP是一种面向连接的可靠的传输层协议,为了达到这个目的,其在设计上使用了差错检测,重传,累计确认,定时器以及用于序号和确认好的字段等等。

TCP的特点

TCP协议有以下特点:

  • 面向连接:TCP的连接不是一条电路的或者虚电路,其连接状完全保留在两个端系统中。
  • 可靠传输:TCP连接在传输消息的过程中保证了数据的可靠性,即保证数据在传输过程中不会丢失。
  • 点对点:一条TCP连接只能连接两个端点。
  • 全双工:如果一台主机上的进程A和另一台主机上的进程B建立了一条TCP连接,那么应用层数据就可以从A进程发送到B进程的同时,B进程也可以发送数据到A进程。

TCP报文段结构

TCP报文段是由首部字段和一个数据字段组成的,数据字段中包含应用程序需要发送的数据。通常报文段中通过MSS(max segment size)来限制报文段数据字段的最大长度。报文段的结构如下:

说明:

  • 源端口号与目的端口号指的是客户端和服务器端应用程序分别使用的端口号。用于识别主机上某一个特定的应用程序。
  • 序号(seq)为TCP报文段中数据其实字节的序号。
  • 确认号字段为(ACK)期待接收的下一个TCP报文段中数据的序号。
  • 首部长度:首部长度表示当前TCP报文的首部的长度,一般为20,即可以看到表中报文首部的长度为20个字节。但是有时选项中有一些内容,这个时候首部长度大于20。
  • 保留字:NULL
  • 标志位:当某一些标志位被设置的时候表达一些特定的含义。
    • UGR标识报文段中存在着紧急数据。其中紧急数据的指针字段存放在后面16位的紧急数据指针中。
    • ACK标识确认号字段生效,一般连接建立之后的内个报文段的ACK被会被设置
    • PSH:不常用
    • SYN:连接建立时的客户端发送的服务端的第一个报文段和服务端响应客户端的报文段中被设置,标识当前为建立连接的过程。
    • FIN:与SYN类似,它是在断开连接时被设置。
  • 互联网检验和:用于错误检测
  • 紧急数据指针:即为上述当UGR标志被设置时标识紧急数据的指针。
  • 选项:应用程序中自定义的首部的其他内容。
  • 数据:应用程序发送的真正数据。

TCP建立连接(三次握手)

TCP连接的过程可以简单描述为以下几个过程:

  • 第一次握手:客户端发送请求连接报文段到服务器。此报文段中SYN被设置为1,同时随机或者指定一个起始序号x。此时客户端进入SYN_SENT(同步已发送)状态。
  • 第二次握手:服务端收到请求报文段之后,向客户端发送确认报文段。确认报文段中ACK设置为1,SYN设置为1,确认号为x+1,同时为自己生成一个序号y。此时服务端进入SYN_RECV状态(同步接受到)。
  • 第三次握手:客户端收到服务端的确认报文段之后,还要给服务端发送一个确认报文段。这个报文段中ACK被设置为1,确认号为y+1。此报文段可以携带数据。

经过上述三个步骤之后,TCP连接建立成功。客户端进入连接建立状态(ESTABLISHED)。然后就可以相互发送数据了。

为什么要经过三次握手?

三次握手的目的是为了防止失效的报文段突然传送到服务端而出现问题。

上述已经失效的报文段是指:如果客户端在发送第一次连接请求的过程中,由于网络原因导致此报文段在某个网络节点滞留较长时间,这个时候TCP传输协议会视为此报文段已经丢失,于是重传。若此滞留的报文段在连接断开之后才到达服务器,这个时候就会出现问题。

若不是使用三次握手,服务器收到失效的报文段之后会建立连接,故之后无法释放TCP资源。导致资源浪费以致于长期会使服务器宕机。

三次握手过程中服务器和客户端程序的行为

  • 服务器必须准备好接受外来的连接,调用socket(),bind()以及listen三个函数来完成,我们称为被动打开。
  • 客户端通过调用connect发起主动打开连接,向服务器发送SYN(同步)包,connect函数阻塞。
  • 服务器通过accept接收到客户端来送的TCP包,需要进行确认同事发送自己的SYN包,此时accept函数阻塞,
  • 客户端收到服务器的SYN包,connect函数返回,并向服务器发送确认。服务器接收到确认之后accept函数返回。

TCP连接断开(四次挥手)

由于TCP连接是全双工的,因此每个方向都必须单独的关闭,也就是发送方和接收方都需要FIN和ACK。客户端和服务器都可以首先主动发送连接终止的报文。当其中一方发送完数据之后即可向另一方发出连接断开的请求。当收到FIN意味着这一方向上没有数据流动,但是一方收到FIN之后仍然可以发送数据。四次挥手的具体过程如下:

  1. 此时TCP连接两端都处于ESTABLISHED的状态,客户端停止发送数据,并发出一个FIN报文段。首部FIN设置为1,序号seq=u(u为客户端传输数据的字后一个字节的序号加1)。客户端进入FIN_WAIT-1状态。
  2. 服务端回复确认报文段,确认号为ack=u+1,序号为seq=v(v为服务端传输数据的最后一个字节序号加1),服务端进入close_wait状态。现在TCP连接处于半关闭状态,服务端如果继续发送数据,客户端依然接收。
  3. 客户端收到确认报文段,进入FIN_WAIT-2状态,服务端发送完数据之后,发出FIN报文段,FIN被置位1,确认号为ack=u+1,然后进入LAST_ACK状态。
  4. 客户端回复确认报文段。ACK=1,确认号为sck=w+1(w为半开半闭时收到的最后一个字节数据的编号),序号为seq=u+1,然后进入TIME_WAIT状态。

一段时间(大约4分钟)之后等待状态结束,连接两端进入CLOSED状态。

参考链接:

Comment and share

  • page 1 of 1

魏传柳(2824759538@qq.com)

author.bio


Tencent


ShenZhen,China