参考:《UNIX 网络编程 · 卷1 : 套接字联网API》
基本的套接字编程
socket函数
想要执行网络 I/O,首先需要调用 socket 函数创建套接字,需要头文件 #include <sys/socket.h>
int socket (int domain, int type, int protocol);
参数:
domain
: 执行协议域,取值如下:
domain | 说明 |
---|---|
AF_INET | IPV4协议 |
AF_INET6 | IPV6协议 |
AF_LOCAL | Unix 域协议 |
AF_ROUTE | 路由套接字 |
AF_KEY | 密钥套接字 |
type
: 套接字类型,取值如下
type | 说明 |
---|---|
SOCK_STREAM | 字节流套接字 |
SOCK_DGRAM | 数据报套接字 |
SOCK_SEQPACKET | 有序分组套接字 |
SOCK_RAW | 原始套接字 |
protocol
: 某个协议类型常值,或者设置为 0。
protocal | 说明 |
---|---|
IPPROTO_TCP | TCP传输协议 |
IPPROTO_UDP | UDP传输协议 |
IPPROTO_SCTP | SCTP传输协议 |
如果使用默认,protocol
可以填0。
返回值:
成功时返回一个 int
型整数,是一个类似于文件描述符的网络套接字描述符。
出错返回 -1。
注:在本函数中,domain
和 type
的组合不一定有效,有效的组合如下所示:
AF_INET | 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 | 是 | 是 |
其中 domain
还有以 PF_ 开头的,还有其他 BSD 支持的协议域,不过多介绍。表格中“是”表示可以组合,但是没有有效的名称,空白是无效组合。
connect 函数(客户端)
如果客户端想要连接服务器,必须使用 connect 函数完成,需要引入头文件 #include <sys/socket.h>
。
int connect(int sockfd, const struct sockaddr *servaddr, socklen_t addrlen);
参数:
sockfd
: 是之前 socket 函数返回的套接字描述符。servaddr
: 指向服务器套接字地址结构体的指针,必须含有服务器的 IP 地址和端口。addrlen
: 指向服务器套接字地址结构体的大小。
返回值:
成功,返回 0;
失败,返回 -1,并设置 error 值。
注意:
- 客户端在调用 connect 前不一定非要调用 bind 函数,因为内核会确定源 IP 地址,并选择一个临时端口作为源端口。
- 如果是 TCP 套接字,调用 connect 函数将激发三次握手过程。在连接建立成功或出错时返回。出错情况如下:
ETIMEDOUT
: 客户端没有收到 SYN 分节的响应。
ECONNREFUSED
: 服务器对客户端 SYN 的响应是 RST(复位),说明服务器在指定的端口上没有进程在等待与之连接(硬件错)。
EHOSTUNREACH / ENETUNREACH
: 客户端发送的 SYN 在某个路由器引发了目的地不可达的 ICMP 错误(软错误)。
bind函数
如果想要将 socket 函数创建的套接字与一个本地协议地址捆绑起来,需要使用 bind 函数,头文件 #include <sys/types.h>
、 #include <sys/socket.h>
。
int bind(int sockfd, const struct sockaddr *myaddr, socklen_t addrlen);
参数:
sockfd
: 调用socket函数返回的套接字。myaddr
: 指向特定协议地址结构体的指针。addrlen
: 特定协议地址结构体的大小。
返回值:
成功,返回 0;
失败,返回 -1,并设置 error 值。
出错情况:
EADDRINUSE
: 地址已经使用
注意:
-
bind 函数捆绑时,可以指定 IP 和端口,也可以不指定,不指定的话内核会要为相应的套接字选择一个临时端口,但是如果是服务器创建出来的套接字地址信息客户端不会知道。
-
bind 指定 IP 端口和不指定产生的结果如下:
IP 端口 结果 非指定 0 内核选择IP和端口 非指定 非0 内核选择IP,进程指定端口 指定本机IP 0 进程指定IP,内核选择端口 指定本机IP 非0 进程指定IP和端口 -
如果想指定地址为通配地址,IPV4 为
INADDR_ANY
,IPV6 为in6addr_any
,头文件为#include <netinet/in.h>
。servaddr.sin_addr.s_addr = htonl(INADDR_ANY); //ipv4 servaddr.sin6_addr = in6addr_any; //ipv6
-
bind 并不返回内核所选择的临时端口的值。因为 const 限定,如果想要得到内核选择的临时端口,需要使用
getsockname
来返回协议地址。
listen函数(服务器)
服务器如果需要监听客户端连接,需要使用 listen 函数,头文件 #include <sys/socket.h>
。
int listen(int sockfd, int backlog);
参数:
sockfd
: 之前 socket 函数创建的套接字描述符backlog
: 规定了内核应该为该套接字排队的最大连接个数。内核为监听套接字维护了两个队列。一个是未完成连接队列,主要是客户端发出 SYN 到服务器,服务器正在等待完成相应的 TCP 三路握手过程,此时套接字处于 SYN_RCVD 状态。另一个是已经完成的队列,此时套接字处于 ESTABLISHED 状态。badklog 就是两个队列之和的最大值。调用 listen 函数后,将已经完成连接队列的队头返回返回给进程;如果队列为空,进程将睡眠,直到 TCP 在该队列中放入一项才唤醒它。不要把 backlog 设置为 0,因为不同的实现对此有不同的解释。老内核一般指定为 5,但是高并发下,该值偏小不够用,新内核支持比较大的值。
返回值:
成功,返回 0。
失败,返回 -1,并设置 error 值。
注意:
- 该函数必须在 accept 之前调用。
- 当使用 socket 函数创建一个套接字时,它默认是一个主动套接字。而 listen 函数将一个未连接的套接字转换为被动套接字,指示内核应该接受该套接字的连接请求。
accept函数(服务器)
上面 listen
函数的 backlog
参数提到一个已完成的队列,accept 函数就是用于从已完成连接的队列对头返回下一个已完成队列,如果已完成队列连接为空,那么进入睡眠(套接字默认为阻塞状态)。头文件 #include <sys/socket.h>
int accept(int sockfd, struct sockaddr *cliaddr, socklen_t *addrlen);
参数:
sockefd
: 监听套接字,是 socket 函数创建的套接字,接受成功后,会返回新的套接字。cliaddr
: 是用来返回对端客户端的地址结构信息。addrlen
: 是客户端套接字地址结构的长度,由该长度确定内核中存放客户端套接字的地址结构的确定字节数。
返回值:
如果返回成功(值 > 0),返回值是内核生成的一个全新的描述符,代表服务器与客户端的连接,代表已连接套接字。一个服务器通常仅创建一个监听套接字,它在服务器的生命周期内会一直存在。但是会创建多个已连接套接字,当服务器完成某一客户端的服务,已连接套接字可能会关闭。
如果失败返回 -1,并设置 error 值。
注意:
如果对接收后的客户端的地址信息不感兴趣,cliaddr
和 addrlen
参数都可以为空指针。
close函数
当处理完成后,关闭套接字描述符,并终止 TCP 连接,需要使用 close 函数,头文件 #include <unistd.h>
。
int close(int sockfd);
参数:
- sockfd : 是一个套接字描述符或文件描述符。
返回值:
成功返回0
失败返回 -1,并设置 error 值。
注意:
调用 close 后,该 sockfd 不能再被使用,但是 TCP 会尝试发送已排队发送到对端的任何数据,发送完毕后发生的是正常的 TCP 连接终止序列。
描述符的引用计数
多进程的编程中,我们一般在父进程中调用 listen
后,然后通过 accept
接受客户端 ,我们调用 fork
函数创建子进程,那么现在 listenfd 和 connfd 在父子进程之间被共享(被复制),然而每个文件或者套接字都有一个引用计数,引用计数通过文件表项中维护,就是打开者着的引用该文件或套接字描述符的个数。那么我们 fork 后,这两个套接字相关联的文件表项的访问计数都变为 2。所以下一步就是,在父进程中关闭已连接套接字 connfd,而在子进程关闭监听套接字 listenfd。
那么如果我们不那么做,父进程对每个 accept 返回的已连接套接字都不调用 close,那么将会发生以下状况:
- 父进程最终将耗尽可用描述符。
- 没有一个客户端连接被终止。就算子进程关闭,只是将引用计数值由 2 变为 1,因为父进程不关闭任何已连接套接字 connfd,这将会妨碍 TCP 连接终止序列的发生,连接将一直打开。
获得套接字关联的地址
getsockname
函数获得某个套接字关联的本地协议地址,头文件为 #include<sys/socket.h>
int getsockname(int sockfd,struct sockaddr* localaddr, socklen_t* addrlen);
getpeername
函数获得某个套接字关联的外地协议地址
int getpeername(int sockfd,struct sockaddr* peeraddr, socklen_t* addrlen);
返回值:成功均返回 0,出错返回 -1。
这两个函数后两个参数都是值-结果参数,所以需要自己定义结构然后传入,结果由这两个函数进行填充。
这两个函数的作用如下:
- 客户端没有调用 bind,直接 connect,那么并不知道连接的本地 IP 和端口是多少,就可以调用 getsockname 来获取。
- 在使用端口号 0 调用 bind,是由内核选择端口号,可以调用 getsockname 返回内核赋予的端口号。
- getsockname 可以获取某个套接字的地址协议族。
- 在服务器和客户端建立连接后,getsockname 可以用于返回内核赋予该连接的本地 IP 地址,但必须是已连接套接字描述符,不能是监听套接字描述符。
- 当一个服务器是通过一个进程调用 exec 执行程序执行时,想要获取客户端地址信息的唯一途径便是调用 getpeername。
TCP套接字编程的注意事项
多进程服务器的信号处理
当 accept 调用返回后,我们调用 fork,在父进程关闭 connfd,由子进程来处理已连接套接字 connfd,当客户端正常终止后,服务器子进程也会终止,这时父进程会发送一个 SIGCHLD
信号。如果我们没有捕捉该信号,那么此信号将被忽略。因为父进程未处理子进程的终止,所以子进程将会处于僵死状态。
我们使用命令查看进程状态
ps -t pts/6 -o pid,ppid,tty,stat,wchan
我们可以看到 stat 状态为 Z 表示僵死状态,我们必须处理僵死状态的进程。
信号就是告知一个进程发生了什么事的通知。也叫软件中软。通常是异步发生,进程不知道什么时候会发生。
信号可以:
-
由一个进程发给另一个进程
-
由内核发给某个进程
SIGCHLD 就是内核在子进程终止时,发给它的父进程的一个信号。每个信号都有一个与其关联的处置,也叫行为。通过调用 sigaction
函数来设定一个信号的处理。
信号的处理有三种选择:
-
提供一个信号处理函数,只要信号发生就调用它,这种行为叫捕获信号。但是 SIGKILL 和 SIGSTOP 不能被捕获。信号处理函数由信号值单一整数参数来调用,没有返回值,原型是
void handler(int signo);
-
可以直接把某个信号处置设置为 SIG_IGN 忽略。但是 SIGKILL 和 SIGSTOP 不能忽略。如:
signal(SIGCHLD, SIG_IGN);
-
可以把某个信号的处置设定为 SIG_DFL 启用它的默认处置。通常是收到信号后终止进程,某些信号还在当前工作目录中生成 .core 核心映像文件。
signal函数
POSIX 处理信号函数通用方法是调用 sigaction
函数。但是该函数的参数需要我们分配并填写比较复杂。sigaction 可以提供更多的功能,详细需要参考 APUE,或者 Linux 的 man 手册。
通常简单的处理方法就是调用 signal 函数,第一个参数是信号名,第二个参数是指向函数的指针,或为 SIG_IGN 和 SIG_DFL。
函数 signal 的函数原型很复杂:
void (*signal(int signo, void (*func)(int)))(int)
为了简单,可以使用 typedef 定义:
typedef void Sigfunc(int);
Sigfunc* signal(int signo, Sigfunc* func);
一个简单的信号处理程序:
void handler(int sig)
{
cout << "signal handler." << endl;
}
int main(int argc, char** argv)
{
signal(SIGALRM, handler);
alarm(1);
return 0;
}
信号处理有以下几个特点:
-
一旦安装了信号处理函数就一直安装着。
-
在一个信号处理函数在运行时,正在被递交的信号是阻塞的。而且传递给 sigaction 函数的 sa_mask 信号集中指定的信号也被阻塞。
-
如果一个信号在被阻塞期间产生了一次或多次,那么该信号被解阻塞之后通常只注销了一次,因为 Unix 信号默认是不排队的。
-
可以使用 sigprocmask 函数选择性的阻塞或解阻塞一组信号是恶可能的。可以让程序在一段临界区代码执行期间,防止捕捉某些信号,保护此代码。
处理SIGCHILD信号
上面说过子进程没被回收会变成僵尸进程,设置僵死状态的目的是维护子进程的信息。以便父进程在某个时刻获取这些信息,包括子进程的进程 ID,终止状态,以及资源利用信息等。如果父进程终止,它的所有僵死的子进程的父进程都被置为 1(init 进程)。有 init 进程来继承并清理它们。
我们不希望僵死进程留存。因为占用内核中空间,可能导致资源耗尽。每次 fork 子进程我们都需要 wait 它们,防止变成僵死进程。因此可以建立一个 SIGCHLID 信号处理函数,在函数体中调用 wait。
void sig_child(int signo)
{
pid_t pid;
int stat;
pid = wait(&stat);
printf("child %d terminated\n", pid);
}
假如父进程阻塞于 accept 调用,子进程运行结束,sig_child 函数执行,这时候父进程捕获信号,内核就会向 accept 调用返回一个 EINTR 错误(被中断的系统调用)。如果父进程不处理,就会中止执行。
标准 C 库中的 signal 函数不会让内核自动重启被中断的系统调用,就是 sigaction 函数的 sa_flags 标志没有设置。但是另外一些新版的系统函数库对 signal 函数进行了处理,内核将重启被中断的系统调用,于是 accpet 就不会返回错误。
为了处理被中断的 accept
调用,可以做以下处理:
for (; ; )
{
clien = sizeof(cliaddr);
if ( (connfd) = accept(listenfd, (SA*)&cliaddr), &clilen) < 0)
{
if (errno == EINTR)
continue;
else
printf("accept error.");
}
}
wait 和 waitpid 函数
上面我们使用 wait 来处理已终结的子进程,wait
和 waitpid
函数定义如下:
#include <sys/wait.h>
pid_t wait(int* statloc);
pid_t waitpid(pid_t pid, int* statloc, int options);
返回值:均返回已终止子进程的进程 ID 号,并通过 statloc 指针返回子进程的终止状态(一个整数)。有三个宏来判断子进程的状态:1. 正常终止,2. 某个信号杀死,3. 作业控制停止等。
区别:
如果一个服务器主进程有 5 个子进程,每个子进程对应一个已连接,当客户端终止时,所有连接在同一时刻终止,子进程将信号 SIGCHLD 传递给父进程。问题在于:所有信号都在信号处理函数处理之前产生,而信号处理函数只执行一次,因为 UNIX 信号默认是不排队的,所以可能会剩余 4 个僵死进程。
现在使用 waitpid,我们在一个循环中调用 waitpid,以获取所有子进程的终止状态。但我们必须指定 WNOHANG
选项,告知 waitpid 在有尚未终止的子进程在运行时不要阻塞。
void sig_chld(int signo)
{
pid_t pid;
int stat;
while ((pid = waitpid(-1, &stat, WNOHANG)) > 0)
{
printf("child %d terminated.", pid);
}
}
不能再循环中调用 wait 的原因是:没有办法防止 wait 在正运行的子进程尚有未终止时阻塞。
accept 返回前连接中止
另一种情况也可以导致 accept 返回一个非致命错误:三路握手完成从而连接建立,客户 TCP 却发送一个 RST(复位)。从服务器看,该连接已由 TCP 排队,等着服务器进程调用 accept 时候 RST 到达,然后服务器调用 accept,在 SVR4 中会返回一个 EPROTO
的 errno 值,而 POSIX 返回的 errno 值必须是 ECONNABORTED
。
POSIX 这样的理由是:在流子系统中发生某些致命的协议相关事件时,也会返回 EPROTO。要是上面 accept 也返回 EPROTO,就不知道是不是再次调用 accept 了。然而换成 ECONNABORTED 只需要再次调用 accept 即可。
SIGPIPE 信号
当一个进程已经向某个收到 RST 的套接字执行写操作时,内核会向进程发送一个 SIGPIPE 信号。该信号的默认行为是终止进程,因此我们必须捕获它,防止服务器不情愿的被终止。
不论该进程是捕捉了该信号,还是简单的忽略,写操作都将返回 EPIPE 错误。