前言

网络编程还是很重要的,不过UNP一书过于厚重了,重点也不够突出。对比之下,尹圣雨的这本《TCP/IP网络编程》就比较简单易读。

screenshot-20211114173525

阅读时跳过了所有Windows实现的部分,只重点阅读部分章节并敲代码,代码GitHub:linux-learn/tcpip-net at master · jlice/linux-learn

理解网络编程和套接字

服务端步骤:

第一步:调用 socket 函数创建套接字。

1
int socket(int domain, int type, int protocol);

第二步:调用 bind 函数分配IP地址和端口号。

1
int bind(int sockfd, struct sockaddr *myaddr, socklen_t addrlen);

第三步:调用 listen 函数转换为可接受请求状态。

1
int listen(int sockfd, int backlog);

第四步:调用 accept 函数受理套接字请求。

1
int accept(int sockfd,struct sockaddr *addr,socklen_t *addrlen);

客户端步骤:

一、调用 socket 函数 和 connect 函数

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

二、与服务端共同运行以收发字符串数据

基于 Linux 的文件操作

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
/*
成功时返回文件描述符,失败时返回-1
path : 文件名的字符串地址
flag : 文件打开模式信息
*/
int open(const char *path, int flag);

/*
成功时返回写入的字节数 ,失败时返回 -1
fd : 显示数据传输对象的文件描述符
buf : 保存要传输数据的缓冲值地址
nbytes : 要传输数据的字节数
*/
ssize_t write(int fd, const void *buf, size_t nbytes);

/*
成功时返回接收的字节数(但遇到文件结尾则返回 0),失败时返回 -1
fd : 显示数据接收对象的文件描述符
buf : 要保存接收的数据的缓冲地址值。
nbytes : 要接收数据的最大字节数
*/
ssize_t read(int fd, void *buf, size_t nbytes);

套接字类型与协议设置

1
2
3
4
5
6
7
/*
成功时返回文件描述符,失败时返回-1
domain: 套接字中使用的协议族(Protocol Family)
type: 套接字数据传输的类型信息
protocol: 计算机间通信中使用的协议信息
*/
int socket(int domain, int type, int protocol);

协议族(Protocol Family)

名称 协议族
PF_INET IPV4 互联网协议族
PF_INET6 IPV6 互联网协议族
PF_LOCAL 本地通信 Unix 协议族
PF_PACKET 底层套接字的协议族
PF_IPX IPX Novel 协议族

套接字类型(Type)

面向连接的套接字(SOCK_STREAM

  • 传输过程中数据不会消失
  • 按序传输数据
  • 传输的数据不存在数据边界(Boundary)

可靠地、按序传递的、基于字节的面向连接的数据传输方式的套接字。

套接字缓冲已满是否意味着数据丢失?

答:缓冲并不总是满的。如果读取速度比数据传入过来的速度慢,则缓冲可能被填满, 但是这时也不会丢失数据,因为传输套接字此时会停止数据传输,所以面向连接的套接字不会发生数据丢失。

面向消息的套接字(SOCK_DGRAM

  • 强调快速传输而非传输有序
  • 传输的数据可能丢失也可能损毁
  • 传输的数据有边界
  • 限制每次传输数据的大小

不可靠的、不按序传递的、以数据的高速传输为目的套接字。

协议

1
2
3
4
// TCP
int tcp_socket = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP);
// UDP
int udp_socket = socket(PF_INET, SOCK_DGRAM, IPPROTO_UDP);

地址族与数据序列

表示 IPV4 地址的结构体

结构体的定义如下:

1
2
3
4
5
6
7
struct sockaddr_in
{
    sa_family_t sin_family;  //地址族(Address Family)
    uint16_t sin_port;       //16 位 TCP/UDP 端口号
    struct in_addr sin_addr; //32位 IP 地址
    char sin_zero[8];        //不使用
};

in_addr 定义如下,它用来存放 32 位IP地址

1
2
3
4
struct in_addr
{
    in_addr_t s_addr; //32位IPV4地址
}

sockaddr_in 类型结构体变量serv_addr的用法:

1
bind(serv_sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr))
1
2
3
4
5
struct sockaddr
{
    sa_family_t sin_family; //地址族
    char sa_data[14];       //地址信息
}
  • 大端序(Big Endian):高位字节存放到低位地址
  • 小端序(Little Endian):高位字节存放到高位地址
1
2
3
4
5
#include <arpa/inet.h>
unsigned short htons(unsigned short);
unsigned short ntohs(unsigned short);
unsigned long htonl(unsigned long);
unsigned long ntohl(unsigned long);

h 代表主机(host)字节序,n 代表网络(network)字节序,s 代表 short,l 代表 long

转换字符串形式的IP地址:

1
2
3
4
5
6
7
in_addr_t inet_addr(const char *string);
/*
成功时返回 1 ,失败时返回 0
string: 含有需要转换的IP地址信息的字符串地址值
addr: 将保存转换结果的 in_addr 结构体变量的地址值
*/
int inet_aton(const char *string, struct in_addr *addr);

把网络字节序整数型IP地址转换成我们熟悉的字符串形式:

1
2
3
4
5
6
char *inet_ntoa(struct in_addr adr);

// 注意:返回类型为char指针,该函数在内部申请了内存并保存了字符串
// 若再次调用该函数,则可能覆盖之前保存的字符串信息
// 因此,调用完该函数后,应立即将字符串信息复制到其它内存空间
str_ptr = inet_ntoa(addr1.sin_addr);

基于TCP的服务端和客户端

TCP服务端客户端函数调用顺序.drawio

TCP函数调用关系.drawio

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <sys/socket.h>
int listen(int sockfd, int backlog);
//成功时返回0,失败时返回-1
//sock: 希望进入等待连接请求状态的套接字文件描述符,传递的描述符套接字参数称为服务端套接字
//backlog: 连接请求等待队列的长度,若为5,则队列长度为5,表示最多使5个连接请求进入队列            

#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
/*
成功时返回文件描述符,失败时返回-1
sock: 服务端套接字的文件描述符
addr: 保存发起连接请求的客户端地址信息的变量地址值
addrlen: 的第二个参数addr结构体的长度,但是存放有长度的变量地址。
*/

#include <sys/socket.h>
int connect(int sock, struct sockaddr *servaddr, socklen_t addrlen);
/*
成功时返回0,失败返回-1
sock:客户端套接字文件描述符
servaddr: 保存目标服务器端地址信息的变量地址值
addrlen: 以字节为单位传递给第二个结构体参数 servaddr 的变量地址长度
*/

IO复用

select函数调用过程.drawio

1
2
3
4
FD_ZERO(fd_set *fdset):将 fd_set 变量所指的位全部初始化成0
FD_SET(int fd,fd_set *fdset):在参数 fdset 指向的变量中注册文件描述符 fd 的信息
FD_ISSET(int fd,fd_set *fdset):若参数 fdset 指向的变量中包含文件描述符 fd 的信息,则返回「真」
FD_CLR(fd_set *fdset):用于在文件描述符集合中删除一个文件描述符。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
#include <sys/select.h>
#include <sys/time.h>

int select(int maxfd, fd_set *readset, fd_set *writeset,
           fd_set *exceptset, const struct timeval *timeout);
/*
成功时返回大于 0 的值,失败时返回 -1
maxfd: 监视对象文件描述符数量
readset: 将所有关注「是否存在待读取数据」的文件描述符注册到 fd_set 型变量,并传递其地址值。
writeset: 将所有关注「是否可传输无阻塞数据」的文件描述符注册到 fd_set 型变量,并传递其地址值。
exceptset: 将所有关注「是否发生异常」的文件描述符注册到 fd_set 型变量,并传递其地址值。
timeout: 调用 select 函数后,为防止陷入无限阻塞的状态,传递超时(time-out)信息
返回值: 发生错误时返回 -1,超时时返回0,。因发生关注的事件返回时,返回大于0的值,该值是发生事件的文件描述符数。
*/

epoll

下面是 epoll 函数的功能:

  • epoll_create:创建保存 epoll 文件描述符的空间
  • epoll_ctl:向空间注册并注销文件描述符
  • epoll_wait:与 select 函数类似,等待文件描述符发生变化

epoll 方式通过如下结构体 epoll_event 将发生变化的文件描述符单独集中在一起。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
struct epoll_event
{
    __uint32_t events;
    epoll_data_t data;
};
typedef union epoll_data {
    void *ptr;
    int fd;
    __uint32_t u32;
    __uint64_t u64;
} epoll_data_t;

声明足够大的 epoll_event 结构体数组后,传递给 epoll_wait 函数时,发生变化的文件描述符信息将被填入数组。因此,无需像 select 函数那样针对所有文件描述符进行循环。

1
2
3
4
5
6
#include <sys/epoll.h>
int epoll_create(int size);
/*
成功时返回 epoll 的文件描述符,失败时返回 -1
size:epoll 实例的大小
*/

Linux 2.6.8 之后的内核将完全忽略传入 epoll_create 函数的 size 函数

1
2
3
4
5
6
7
8
9
#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
/*
成功时返回 0 ,失败时返回 -1
epfd:用于注册监视对象的 epoll 例程的文件描述符
op:用于制定监视对象的添加、删除或更改等操作
fd:需要注册的监视对象文件描述符
event:监视对象的事件类型
*/

op:

  • POLL_CTL_ADD:将文件描述符注册到 epoll 例程
  • EPOLL_CTL_DEL:从 epoll 例程中删除文件描述符
  • EPOLL_CTL_MOD:更改注册的文件描述符的关注事件发生情况

epoll_event.events:

  • EPOLLIN:需要读取数据的情况
  • EPOLLOUT:输出缓冲为空,可以立即发送数据的情况
  • EPOLLPRI:收到 OOB 数据的情况
  • EPOLLRDHUP:断开连接或半关闭的情况,这在边缘触发方式下非常有用
  • EPOLLERR:发生错误的情况
  • EPOLLET:以边缘触发的方式得到事件通知
  • EPOLLONESHOT:发生一次事件后,相应文件描述符不再收到事件通知。因此需要向 epoll_ctl 函数的第二个参数传递 EPOLL_CTL_MOD ,再次设置事件。
1
2
3
4
5
6
7
8
9
#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
/*
成功时返回发生事件的文件描述符,失败时返回 -1
epfd : 表示事件发生监视范围的 epoll 例程的文件描述符
events : 保存发生事件的文件描述符集合的结构体地址值
maxevents : 第二个参数中可以保存的最大事件数
timeout : 以 1/1000 秒为单位的等待时间,传递 -1 时,一直等待直到发生事件
*/

线程创建及运行

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
#include <pthread.h>

int pthread_create(pthread_t *restrict thread,
                   const pthread_attr_t *restrict attr,
                   void *(*start_routine)(void *),
                   void *restrict arg);
/*
成功时返回 0 ,失败时返回 -1
thread : 保存新创建线程 ID 的变量地址值。线程与进程相同,也需要用于区分不同线程的 ID
attr : 用于传递线程属性的参数,传递 NULL 时,创建默认属性的线程
start_routine : 相当于线程 main 函数的、在单独执行流中执行的函数地址值(函数指针)
arg : 通过第三个参数传递的调用函数时包含传递参数信息的变量地址值
    //传递参数变量的地址给start_routine函数
*/

线程相关代码编译时需要添加 -lpthread 选项声明需要连接到线程库

1
2
3
4
5
6
7
8

#include <pthread.h>
int pthread_join(pthread_t thread, void **status);
/*
成功时返回 0 ,失败时返回 -1
thread : 该参数值 ID 的线程终止后才会从该函数返回
status : 保存线程的 main 函数返回值的指针变量地址值
*/
1
2
3
4
5
6
7
8
// 线程不安全:
struct hostent *gethostbyname(const char *hostname);
// 线程安全
struct hostent *gethostbyname_r(const char *name,
                                struct hostent *result,
                                char *buffer,
                                int intbuflen,
                                int *h_errnop);

线程安全函数结尾通常是 _r 。但是使用线程安全函数会给程序员带来额外的负担,可以通过以下方法自动将 gethostbyname 函数调用改为 gethostbyname_r 函数调用。

声明头文件前定义 _REENTRANT 宏。

无需特意更改源代码加,可以在编译的时候指定编译参数定义宏。

1
gcc -D_REENTRANT mythread.c -o mthread -lpthread
1
2
3
4
5
6
7
8
9
#include <pthread.h>
int pthread_mutex_init(pthread_mutex_t *mutex,
                       const pthread_mutexattr_t *attr);
int pthread_mutex_destroy(pthread_mutex_t *mutex);
/*
成功时返回 0,失败时返回其他值
mutex : 创建互斥量时传递保存互斥量的变量地址值,销毁时传递需要销毁的互斥量地址
attr : 传递即将创建的互斥量属性,没有特别需要指定的属性时传递 NULL
*/

进入临界区前调用的函数就是 pthread_mutex_lock 。调用该函数时,发现有其他线程已经进入临界区,则 pthread_mutex_lock 函数不会返回,直到里面的线程调用 pthread_mutex_unlock 函数退出临界区位置。也就是说,其他线程让出临界区之前,当前线程一直处于阻塞状态。

1
2
3
4
5
pthread_mutex_lock(&mutex);
//临界区开始
//...
//临界区结束
pthread_mutex_unlock(&mutex);
1
2
3
4
5
6
7
#include <semaphore.h>
int sem_post(sem_t *sem);
int sem_wait(sem_t *sem);
/*
成功时返回 0 ,失败时返回其他值
sem : 传递保存信号量读取值的变量地址值,传递给 sem_post 的信号量增1,传递给 sem_wait 时信号量减一
*/

调用 sem_init 函数时,操作系统将创建信号量对象,此对象中记录这「信号量值」(Semaphore Value)整数。该值在调用 sem_post 函数时增加 1 ,调用 wait_wait 函数时减一。但信号量的值不能小于 0 ,因此,在信号量为 0 的情况下调用 sem_wait 函数时,调用的线程将进入阻塞状态(因为函数未返回)。当然,此时如果有其他线程调用 sem_post 函数,信号量的值将变为 1 ,而原本阻塞的线程可以将该信号重新减为 0 并跳出阻塞状态。

1
2
3
4
5
sem_wait(&sem);//信号量-1...
// 临界区的开始
//...
//临界区的结束
sem_post(&sem);//信号量+1...

如果一个线程结束运行但没有被join,则还有一部分资源没有被回收 比如在Web服务器中当主线程为每个新来的链接创建一个子线程进行处理的时候,主线程并不希望因为调用pthread_join而阻塞(因为还要继续处理之后到来的链接),这时可以在子线程中加入代码

1
pthread_detach(pthread_self()) 

或者父线程调用

1
pthread_detach(thread_id)  //(非阻塞,可立即返回) 

这将该子线程的状态设置为detached,则该线程运行结束后会自动释放所有资源。