网络编程初识
网络编程就是编写程序使两台联网的计算机相互交换数据,这就是网络编程的全部内容。那么怎么交换数据呢?首先需要物理层的支持,但是由于大部分计算机都连接到了互联网,所以我们不必担心这一点。我们只需要关心如何编写交换数据的软件就行。但实际上这也不用愁,因为操作系统给我们提供了“套接字”(socket)的部件。套接字就是网络传输用的软件设备。只要服务端和客户端都创建了套接字,并且把它们连接起来了,那么我们相当构建出了一条通信链路就能交换数据了。
创建套接字并建立连接
基于TCP实现
在创建套接字之前首先我们需要选择运输层所使用的协议,也就是TCP/UDP。TCP是面向连接的,可以保证数据传输的可靠性,还有流量控制机制并且我们要注意在网络编程中TCP的数据传输是没有边界的。也就是多次调用write函数传递的字符串有可能一次性传递到客户端。 服务器端:
- 调用socket函数创建套接字
- 调用bind函数分配IP地址和端口号
- 调用listen函数转化为可接受请求状态
- 调用accept函数受理连接请求
客户端:
- 调用socket函数创建套接字
- 调用connect函数向服务器端发送连接请求
基于UDP实现
UDP是面向消息的,也就是无连接的。UDP无法保证数据传输的可靠性。并且没有流量控制机制。UDP的优点在于它在结构上比TCP更加简洁。并且不会发送类似ACK的应答消息,也不会像SEQ那样给数据包分配序号。因此,UDP的性能有时要比TCP高出很多。在收发数据量比较越大时TCP的传输速率越接近UDP的传输速率。TCP比UDP慢的原因通常有以下两点:
- 收发数据前后进行的连接设置及清除过程
- 收发数据过程中为保证可靠性而添加的流控制。
服务器端:
- 调用socket创建套接字
- 调用bind函数分配IP地址和端口号
客户端:
任何套接字都要分配IP地址和端口号。而UDP的客户端是在进行数据交换时通过sendto这个函数自动分配的。
连接之后的数据交换
- 对于TCP而言,由write函数发送数据,read函数接受数据。
- 对于UDP而言,由sendto发送数据,recvfrom函数接收数据。如果在调用sendto函数时发现未分配地址信息,则在首次调用sendto函数时给相应套接字自动分配IP和端口。而且此时分配的地址一直保留到程序结束为止,因此也可用来与其它UDP套接字进行数据交换。
优雅的断开套接字的连接
在Linux里面套接字断开连接直接调用close函数就行了。不过直接调用close函数代表完全断开连接。完全断开连接表示即不能接收数据也不能发送数据。因此有时调用close就显得不太优雅。为了解决这类问题,“只关闭一部分数据交换中使用的流”的方法应运而生。断开一部分连接是指,可以传输数据但无法接收,或可以接收数据但无法传输。顾名思义就是只关闭流的一半。
使用的函数是shutdown。
int shutdown(int sock,int howto); sock是需要断开的套接字文件描述符 howto传递断开方式信息
第二个参数的值如下所示
- SHUT_RD:断开输入流
- SHUT_WR:断开输出流
- SHUT_RDWR:同时断开I/O流
多进程服务器端
进程概述
1.进程的创建:fork()函数。成功时返回进程ID,失败时返回-1。并且fork函数创建的是调用的进程副本。也就是说,并非根据完全不同的程序创建进程,而是复制正在运行的、调用fork函数的进程。 2.僵尸进程:是指在进程完成工作后没有被销毁,这些进程将会变成僵尸进程,它们将会占据系统中的重要资源。 3.僵尸进程产生的原因:
- 传递参数并且调用exit函数
- main函数中执行return语句并返回值。
4.僵尸进程的销毁:
- 调用wait函数。不过在调用wait函数时,如果没有已终止的子进程,那么程序将阻塞知道有子进程终止,因此需要谨慎调用该函数。
函数定义:pid_t wait(int *statloc);成功时返回终止的子进程ID,失败时返回-1。statloc保存exit函数的参数值或者main函数的返回值。 - 调用waitpid函数。它的函数定义如下:
pid_t waitpid(pid_t pid,int *staloc,int options); pid表示终止子进程的ID,或者传递-1,表示等待任意子进程终止。statloc和wait的参数有相同的含义,option传递头文件里面的sys/wait.h中声明的常量WNOHANG,即使没有终止的子进程也不会进入阻塞状态,而是返回0并退出函数。
信号处理
我们已经知道了进程创建和销毁的方法,但是还有一个问题没有解决:子进程究竟何时终止?调用waitpid函数后要无休止的等待吗?我们就可以引入信号处理机制。此处的“信号”是在特定事件发生时由操作系统向进程发送的消息。 1.注册信号:例如当进程发现自己的子进程结束时,请求操作系统调用特定的函数,这就是一个注册信号的过程。该请求通过如下函数调用完成: void (*signal(int signo,void (*func)(int)))(int); 调用上面的函数时,第一个参数为特殊情况的信息,第二个参数为特殊情况下将要调用的函数的地址值(指针)。发生第一个参数代表的情况时,调用第二个参数所指向的函数。下面给出可以在signal函数中注册的部分特殊情况和对应的常数。
- SIGALRM:已到通过调用alarm函数注册的时间。
- SIGINT:输入CTRL+C。
- SIGCHLD:子进程终止。
2.利用sigaction函数进行信号处理: 它类似于signal函数,而且完全可以替代它,也更加稳定。之所以稳定是因为:signal函数在UNIX系列的不同操作系统中可能存在区别,但是sigaction函数完全相同。 int sigaction(int signo,const struct sigaction *act,struct sigaction *oldact); signo:与signal相同,传递信号信息。 act:对应第一个参数的信号处理函数信息。 oldact:通过此参数获取之前注册的信号处理函数指针,若不需要则传递0;
struct sigaction
{
void (*sa_handler)(int);
sigset_t sa_mask;
int sa_flags;
}
此结构体的sa_handler成员保存处理函数的指针值,sa_mask和sa_flags所有位都初始化为0即可。
基于多任务的并发服务器
之前的客户端每次只能向一个客户端提供服务。因此我们扩展服务器端,使其可以同时向多个客户端提供服务。当有5个客户端请求服务时,服务器端将创建5个子进程来提供服务。服务器端提供服务过程如下:
- 服务器端(父进程)通过调用accept函数受理连接请求。
- 此时获取的套接字文件描述符创建并传递给子进程。
- 子进程利用传递来的文件描述符提供服务。
注意:其实无需向子进程传递套接字文件描述符的方法,因为子进程会复制父进程所拥有的所有资源。
分割TCP的I/O程序
客户端的父进程负责接收数据,额外创建的子进程负责发送数据。分割后,不同的进程分别负责输入和输出。 分割I/O程序的优点有:
- 程序的实现更加简单。
- 可以提高频繁交换数据的程序性能。
进程间通信
进程间通信意味着两个不同进程间可以交换数据,为了完成这一点,操作系统中应该提供两个进程可以同时访问的内存空间。
通过管道实现进程间通信
1.管道的创建:管道并非属于进程的资源,而是和套接字一样,属于操作系统。下面是创建管道的函数。 int pipe(int filedes[2]); filedes[0]通过管道接收数据时使用的文件描述符,即管道出口。 filedes[1]通过管道传输数据时使用的文件描述符,即管道入口。 2.通过管道进行进程间的双向通信: 可以创建两个进程通过1个管道进行双向交换。 也可以创建两个管道来完成双向通信。一个管道用来接收数据,另一个管道用来传输数据。
基于select的I/O复用
为了构建并发服务器,只要有客户端连接请求就会创建新进程。这确实是实际操作中的一种方案,但这种方案也是有着明显的缺点的。因为在创建进程时需要付出极大代价。因为创建一个进程需要大量的运算和内存空间。而且由于每一个进程都有独立的内存空间,所以相互间的数据交换也要采用相对复杂的方法。所以我们就需要提供一个不需要创建多个进程就能向多个客户端提供服务的方法,这种方法就是I/O复用技术。 一、select函数介绍 int select(int maxfd,fd_set *readset,fd_set *writeset,fd_set *exceptset,const struct timeval *timeout);
maxfd:监视对象描述符的数量
readset:将所有关注“是否存在待读取数据”的文件描述符注册到fd_set变量,并且传递其地址值。
writeset:将所有关注“是否可传输无阻塞数据”的文件描述符注册到fd_set变量,并且传递其地址值。
exceptset:将所有关注“是否发生异常”的文件描述符注册到fd_set变量,并且传递其地址值。
timeout:调用select后,为防止陷入无限阻塞的状态,传递超时信息。
使用select函数时可以将多个文件描述符集中到一起同一监视。项目如下:
- 是否存在套接字接收数据?
- 无需阻塞传输的套接字有哪些?
- 哪些套接字发生了异常?
二、select函数的调用方法和顺序 1.设置文件描述符 select函数可以同时监视多个文件描述符。此时首先需要将要监视的文件描述符集中到一起,集中时按照监视项进行区分成三类。使用fd_set数组变量监视文件描述符。该数组是存有0和1的位数组。该数组的下标就是文件描述符。下标对应的数组元素如果为1,表示该对象是监视对象。 fd_set变量中的操作:
- FD_ZERO(fd_set *fdset):将fd_set变量的所有位初始化为0.
- FD_SET(int fd,fd_set *fdset):在参数fdset指向的变量中注册描述符fd的信息。
- FD_CLR(int fd,fd_set *fdset):从参数fdset指向的变量中清除文件描述符fd的信息。
- FD_ISSET(int fd,fd_set *fdset):若参数fdset指向的变量中包含文件描述符fd的信息,则返回“真”;
2.设置监视范围及超时 文件的监视范围与select函数的第一个参数有关。
select函数的超时时间与select函数的最后一个参数有关,其中timeval结构体的定义如下:
struct timeval
{
long tv_sec; //seconds
long tv_usec; //microseconds
}
本来select函数只有在监视的文件描述符发生变化时才返回。如果未发生变化,就会进入阻塞状态。指定超时时间就是为了防止这种情况的发生。通过上述声明的结构体,将秒填入tv_sec成员,将微秒填入tv_usec成员。即使文件描述符中未发生变化,只要过了指定时间,也可以从函数中返回。如果不想设置超时,则传递NULL参数。
多种I/O函数
1.send&recv函数
#include <sys/socket.h>
ssize_t recv(int sockfd, void *buff, size_t nbytes, int flags);
ssize_t send(int sockfd, const void *buff, size_t nbytes, int flags);
recv 和send的前3个参数等同于read和write。 flags参数值为0或: 2.readv&writev函数 它们只需一次系统调用就可以实现在文件和进程的多个缓冲区之间传送数据,免除了多次系统调用或复制数据的开销。 readv()称为散布读,即将文件中若干连续的数据块读入内存分散的缓冲区中。 writev()称为聚集写,即收集内存中分散的若干缓冲区中的数据写至文件的连续区域中。
#include <sys/uio.h>
ssize_t readv(int fildes, const struct iovec *iov, int iovcnt);
ssize_t writev(int fildes, const struct iovec *iov, int iovcnt);
参数fildes是文件描述符 iov是一个结构数组,它的每个元素指明存储器中的一个缓冲区。结构类型iovec有下述成员,分别给出缓冲区的起始地址和字节数:
struct iovec {
void *iov_base
size_t iov_len
}
参数iovcnt指出数组iov的元素个数,元素个数至多不超过IOV_MAX。Linux中定义IOV_MAX的值为1024。
套接字和标准I/O
1、I/O操作是系统的基础。 I/O 表示的input【输入】和output【输出】 。I/O操作是系统实现的基础。如果没有I/O操作,所以有的系统文件将无法存储,更谈不上处理与分析,系统运行的结果也不为用户所见。 2、系统IO与标准IO的区别 I/O 分为标准IO 和系统IO 。标准io称为stdio,系统IO又称为文件IO。系统IO是内核提供给用户处理IO操作的接口。例如:标准C是不能处理输入输出问题的。必须借助于内核提供的接口实现对program的输入输出处理。标准I/O 是在系统IO的基础上进行的二次封装。以屏蔽不同体系结构之间的差异,增加程序的可移植性。 标准I/O是基于系统IO实现的。fopen是基于open方法实现,fclose是基于close实现的。fgetc,fputc,fgets,fputs,fread,fwrite,是基于read和write方法是实现,fseek,ftell,rewind是基于lseek方法实现。 3、标准I/O的优点:
- 标准I/O函数具有良好的移植性
- 标准I/O可以利用缓冲提高性能
4、标准I/O的缺点
- 不容易进行双向通信
- 有时可能频繁调用fflush函数
- 需要以FILE结构体指针的形式返回文件描述符
利用fputs&fgets这些标准I/O函数比起于read&write这些函数能大幅度提高性能。 5、使用标准I/O函数 创建套接字时返回文件描述符,而为了使用标准I/O函数,只能将其转换为FILE结构体指针。下面介绍转换方法:
利用fdopen函数转换为FILE结构体指针 FILE *fdopen(int fildes,const char *mode); fildes:需要转换的文件描述符 mode:将要创建的FILE结构体指针的模式信息。 创建成功返回转换的FILE结构体指针
利用fileno函数转换为文件描述符 int fileno(FILE *stream); 成功时返回转换后的文件描述符
分离I/O流
1、分离的目的:
- 为了将FILE指针按读模式和写模式加以区分
- 可以区分读写模式降低实现难度
- 通过区分I/O缓冲提高缓冲性能
2、文件描述符的复制和半关闭 因为一般读模式FILE指针和写模式的FILE指针都是基于同一文件描述符创建的。因此针对任意一个FILE指针调用fclose函数都会关闭文件描述符,也就终止了套接字。为了分离I/O流,我们可以复制文件描述符,然后利用各自的文件描述符生成读模式的FILE指针和写模式的FILE指针。这就为半关闭准备好了条件。也就是说,针对写模式的FILE指针调用fclose函数时,只能销毁与该FILE指针相关的文件描述符,无法销毁套接字。 下面给出文件描述符的复制方法,通过下列两个函数之一完成。
#include<unistd.h>
int dup(int fildes);
int dup2(int fildes,int fildes2);
成功时返回复制的文件描述符,失败时返回-1。 fildes:需要复制的文件描述符 fildes2:明确指定的文件描述符整数值
优于select的epoll
epoll的理解以及应用
select复用方式其实由来已久,因此,利用该技术后,无论如何优化程序性能也无法同时接入上百个客户端。这种select方式并不适合以web服务器端开发为主流的现代开发环境,所以我们要学习Linux平台下的epoll。 一、基于select的I/O复用技术速度慢的原因
- 调用select函数后常见的针对所有文件描述符的循环语句
- 每次调用select函数时都需要向该函数传递监视对象信息
二、select函数也有优点
- 服务器端接入者少
- 服务器应具有兼容性
在这种情况下也可使用select
三、实现epoll时必要的函数和结构体 epoll函数的优点:
- 无需编写以监视状态为变化为目的的针对所有文件描述符的循环语句
- 调用对应于select函数的epoll_wait函数时无需每次传递监视对象信息
下面介绍epoll服务器端实现中需要的三个函数:
1.epoll_create:创建保存epoll文件描述符的空间。
#include<sys/epoll.h>
int epoll_create(int size);
需要注意的是:通过参数size传递的值并非决定epoll例程的大小,该值只是向操作系统提供的建议。仅供操作系统参考。epoll_create函数创建的资源与套接字相同,也会返回文件描述符。也由操作系统管理 2. epoll_ctl:向空间注册并注销文件描述符
int epoll_ctl(int epfd,int op,int fd,struct epoll_event *event)
epfd:用于注册监视对象的epoll例程的文件描述符 op:用于指定监视对象的添加、删除或更改等操作。 fd:需要注册的监视对象文件描述符。 event:监视对象的事件类型。
第二个参数传递的常量:
- EPOLL_CTL_ADD:将文件描述符注册到epoll例程
- EPOLL_CTL_DEL:从epoll例程中删除文件描述符
- EPOLL_CTL_MOD:更改注册的文件描述符的关注事件发生情况
第四个参数:epoll方式中通过如下结构体epoll_event将发生变化的文件描述符单独集中到一起。
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的成员events中可以保存的常量及所指的事件类型。
- EPOLLIN:需要读取数据的情况
- EPOLLOUT:输出缓冲为空,可以立即发送数据的情况
- EPOLLPRI:收到OOB数据的情况
- EPOLLRDHUP:断开连接或半关闭的情况,这在边缘触发的方式下非常有用
- EPOLLERR:发生错误的情况
- EPOLLET:以边缘触发的方式得到事件通知
- EPOLLONESHOT:发生一次事件后,相应文件描述符不再收到事件通知。因此需要向epoll_ctl函数的第二个参数传递EPOLL_CTL_MOD,再次设置事件。
3.epoll_wait:与select函数类似,等待文件描述符发生变化。
int epoll_wait(int epfd,struct epoll_event *events,int maxevents,int timeout);
epfd:表示发生监视范围的epoll例程的文件描述符 events:保存发生事件的文件描述符集合的结构体地址值 maxevents:第二个参数中可以保存的最大事件数 timeout:以1/1000秒为单位的等待时间,传递-1时,一直等待到事件发生
条件触发和边缘触发
1.条件触发 条件触发方式中,只要输入缓冲中有数据就会一直通知该事件。select和epoll默认以条件触发方式工作。
2.边缘触发 输入缓冲收到数据时,仅注册一次该事件,即使缓冲中还有数据,也不会再进行注册。由于只注册一次事件,故发生输入相关事件时,应该读取全部的缓冲中的数据。故不能使用阻塞式的函数,会引起长时间停顿。一定要使用非阻塞式的函数。
3.边缘触发的优点:可以分离接受数据和处理数据的时间点。
多线程服务器端的实现
一、引入线程的背景 多进程的缺点可概括如下: 1.创建进程的过程会带来一定的开销。 2.为了完成进程间的数据交换,需要采用特殊的IPC技术 3.每秒少则数十次、多则数千次的“上下文切换”数创建进程时的最大开销。
线程的优点有: 1.线程的创建和上下文切换都比进程要快 2.线程间交换数据时无需特殊技术
二、线程的创建和执行流程 1.线程的创建函数如下:
#include<pthread.h>
int pthread_create(pthread_t *restrict thread,const pthread_attr_t *restrict attr,void *(*start_routine)(void*),void *restrict arg);
thread:保存新创建线程ID的变量地址值。 attr:用于传递线程属性的参数,传递NULL时,创建默认属性的进程 start_routine:相当于线程main函数的、在单独执行流中执行的函数地址值。 arg:通过第三个参数传递调用函数时包含传递参数信息的变量地址值。
三、线程存在的问题和临界区 为了解决线程同步访问的问题,我们可以采用互斥量和信号量 1.互斥量 互斥量的创建和销毁:
int pthread_mutex_init(pthread_mutex_t *mutex,const pthread_mutexattr_t *attr);
int pthread_mutex_destroy(pthread_mutex_t *mutex);
mutex:创建互斥量时传递保存互斥量的变量地址值,销毁时传递需要销毁的互斥量地址值。 attr:传递即将创建的互斥量属性,没有特别需要指定的属性时传递NULL;
互斥量的加锁与解除
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
2.信号量 信号量的创建和销毁:
#include<semaphore.h>
int sem_init(sem_t *sem,int pshared,unsigned int value);
int sem_destroy(sem_t *sem);
sem:创建信号量时传递保存信号量的变量地址值,销毁时传递需要销毁的信号量地址值。 pshared:传递其它值时,创建可由多个进程共享的信号量;传递0时,创建只允许1个进程内部使用的信号量。我们需要完成同一进程内的线程同步,故传递0; value:指定新创建的信号量的初始值
信号量中类似于lock和unlock的函数
int sem_post(sem_t *sem);
int sem_wait(sem_t *sem);
四、销毁线程的三种方法
- 调用pthread_join函数
- 调用pthread_detach函数
|