C++ TinyWebServer项目总结(13. 多进程编程)

devtools/2024/11/20 7:22:01/

本章讨论Linux多进程编程的以下内容:

  • 复制进程映像的fork系统调用和替换进程映像的exec系列系统调用。
  • 僵尸进程以及如何避免僵尸进程。
  • 进程间通信(Inter Process Communication,IPC)最简单的方式:管道。
  • 三种System V进程间通信方式:信号量、消息队列、共享内存。它们是由AT&T System V2版本的UNIX引入的,所以统称为System V IPC。
  • 在进程间传递文件描述符的通用方法:通过UNIX本地域socket传递特殊的辅助数据。(关于辅助数据,参考《Linux 高性能服务器编程》P85)

fork 系统调用

Linux下创建新进程的系统调用是fork

#include <sys/types.h>
#include <unistd.h>
pid_t fork(void);

该函数的每次调用都返回两次,在父进程中返回的是子进程的PID,在子进程中则返回0,该返回值是后续代码判断当前进程是父进程还是子进程的依据。fork系统调用失败时返回-1,并设置errno。

fork函数复制当前进程,在内核进程表中创建一个新的进程表项,新的进程表项中很多属性和原进程相同,如堆指针、栈指针、标志寄存器的值,但也有很多属性被赋予了新值,如子进程的PPID被设置成原进程的PID,信号位图被清除(原进程设置的信号处理函数不再对新进程起作用)。

子进程的代码和父进程完全相同,同时它还会复制父进程的数据(堆数据、栈数据、静态数据),数据的复制采用的是写时复制(copy on write),即只有在任一进程(父进程或子进程)对数据执行了写操作时,复制才会发生(先是缺页中断,然后操作系统给子进程分配内存并复制父进程的数据),即便如此,如果我们在程序中分配了大量内存,那么使用fork函数时也应当谨慎,尽量避免没必要的内存分配和数据复制。

此外,创建子进程后,父进程中打开的文件描述符默认在子进程中也是打开的,且文件描述符的引用计数加1。父进程的用户根目录、当前工作目录等变量的引用计数也会加 1。

exec 系列系统调用

有时我们需要在子进程中执行其他程序,即替换当前进程映像,这就需要以下exec系列函数之一:

#incldue <unistd.h>
extern char** environ;int execl(const char* path, const char* arg, ...);
int execlp(const char* file, const char* arg, ...);
int execle(const char* path, const char* arg, ..., char* const envp[]);
int execv(const char* path, char* const argv[]);
int execvp(const char* file, char* const argv[]);
int execve(const char* path, char* const arg[], char* const envp[]);
参数

path:指定可执行文件的完整路径;

file:文件名,该文件的具体位置在环境变量PATH中搜寻。

arg:接受可变参数,

argv:则接受参数数组,它和 arg 都会被传递给新程序(pathfile参数指定的程序)的main函数。

envp:用于设置新程序的环境变量,如果未设置它,则新程序将使用由全局变量environ指定的环境变量。

返回值

一般,exec函数是不返回的,除非出错,此时它返回-1,并设置errno。如果没出错,则原进程中exec调用之后的代码都不会执行,因为此时原程序已经被exec的参数指定的程序完全替换(包括代码和数据)。

exec函数不会关闭原进程打开的文件描述符,除非该文件描述符被设置了类似SOCK_CLOEXEC的属性。

SOCK_CLOEXEC属性用于在子进程中关闭 socket,见《Linux 高性能服务器编程》P75

处理僵尸进程

对多进程程序而言,父进程一般需要跟踪子进程的退出状态,因此,当子进程结束运行时,内核不会立即释放该进程的进程表表项,以满足父进程后续对该子进程退出信息的查询(如果父进程还在运行)。

子进程进入僵尸态的两种情况:

  • 在子进程结束运行后,父进程读取其退出状态前,我们称该子进程处于僵尸态。
  • 父进程结束或者异常终止,而子进程继续运行时,此时子进程的PPID将被操作系统设置为1,即init进程,init进程接管了该子进程,并等待它结束。在父进程退出之后,子进程退出之前,该子进程也处于僵尸态。

如果父进程没有正确处理子进程的返回信息,子进程将停留在僵尸态,并占据着内核资源,这是不能容许的,因为内核资源有限,以下函数在父进程中调用,以等待子进程的结束,并获取子进程的返回信息,从而避免了僵尸进程的产生,或使子进程从僵尸态结束:

#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int* stat_loc);
pid_t waitpid(pid_t pid, int *stat_loc, int option);

wait函数将阻塞进程,直到该进程的某个子进程结束运行,它返回结束运行的子进程的PID,并将该子进程的退出状态信息存储于stat_loc参数指向的内存中,sys/wait.h文件中定义了以下宏来帮助解释子进程的退出状态信息:

上图中有一个错误,WIFSTOPPED宏是用来判断子进程是否是被信号暂停。

wait函数的阻塞特性不是服务器程序期望的,而waitpid函数解决了这个问题。waitpid函数只等待由pid参数指定的子进程,如果pid参数取值为-1,那么它就和wait函数相同,即等待任意一个子进程结束。waitpid函数的stat_loc参数的含义和wait函数的stat_loc参数的相同。options参数可以控制waitpid函数的行为,该参数最常用的取值为WNOHANG,此时waitpid函数是非阻塞的,如果pid指定的目标子进程尚未终止,则waitpid函数立即返回0,如果目标子进程确实正常退出了,则waitpid函数返回该子进程的PID。waitpid函数失败时返回-1并设置errno。

要在事件已经发生的情况下执行非阻塞调用才能提高程序的效率。对于 waitpid 函数,我们最好在某个子进程退出之后再调用它。

当一个进程结束时,它将给父进程发送一个SIGCHLD信号,我们可以在父进程中捕获SIGCHLD信号,并在信号处理函数中调用waitpid函数以“彻底结束”一个子进程:

static void handle_child(int sig) {pid_t pid;int stat;while ((pid = waitpid(-1, &stat, WNOHANG)) > 0) {// 对结束的子进程进行善后处理}
}

管道

管道可以实现进程内部的通信。pipe 函数

管道也是父进程和子进程间通信的常用手段。

管道能在父、子进程间传递数据,利用的是调用fork后两个管道文件描述符都保持打开,一对这样的文件描述符能保证父子进程间一个方向的数据传输,父进程和子进程必须有一个关闭fd[0],另一个关闭fd[1]

如果要实现父子进程之间的双向数据传输,可以使用两个管道。socket编程接口提供了一个创建全双工管道的系统调用socketpair

管道只能用于有关联的两个进程(如父、子进程)间的通信,而以下要讨论的三种System V IPC 能用于无关联的多个进程之间的通信,因为它们都使用一个全局唯一的键值来标识一条信道。有一种特殊的管道称为FIFO(First In First Out,先进先出),也叫命名管道,它也能用于无关联进程之间的通信,但FIFO管道在网络编程中用得不多,所以我们不讨论它。

信号量

信号量原语

多个进程同时访问系统上某个资源时,如同时写一个数据库的某条记录,或同时修改某个文件,就需要考虑进程同步问题,以确保任一时刻只有一个进程可以拥有对资源的独占式访问。通常,进程对共享资源的访问的代码只是很短的一段,但这段代码引发了进程之间的竞态条件,我们称这段代码为关键代码区,或临界区,对进程同步,就是确保任一时刻只有一个进程能进入关键代码段。

Dekker算法和Peterson算法试图从语言本身(不需要内核支持)解决进程同步问题,但它们依赖于忙等待,即进程要持续不断地等待某个内存位置状态的改变,这种方式的CPU利用率太低,不可取。

Dijkstra提出的信号量(Semaphore)是一种特殊的变量,它只能取自然数值且只支持两种操作:等待(wait)和信号(signal)。但在Linux/UNIX中,等待和信号都已经具有特殊含义,所以对信号量的这两种操作更常用的称呼是P、V操作,这两个字母来自荷兰语单词passeren(传递,就好像进入临界区)和vrijgeven(释放,就好像退出临界区)。

假设有信号量SV,对它的P、V操作含义如下:

  • P(SV),如果SV的值大于0,就将它减1,如果SV的值为0,则挂起进程的执行。
  • V(SV),如果有其他进程因为等待SV而挂起,则唤醒之,如果没有,则将SV加1。

信号量的取值可以是任何自然数,但最常用的、最简单的信号量是二进制信号量,它只能取0或1两个值,我们仅讨论二进制信号量。使用二进制信号量同步两个进程,以确保关键代码段的独占式访问的例子:

上图中,当关键代码段可用时,二进制信号量SV的值为1,进程A和B都有机会进入关键代码段,如果此时进程A执行了P(SV)操作将SV减1,则进程B再执行P(SV)操作就会被挂起,直到进程A离开关键代码段,并执行V(SV)操作将SV加1,关键代码段才重新变得可用。

不能使用普通变量来模拟二进制信号量,因为所有高级语言都没有一个原子操作可以同时完成以下两步操作:检测变量是否为true/false,如果是则将它设置为false/true。

Linux信号量的API定义在sys/sem.h头文件中,主要包括3个系统调用:semgetsemopsemctl。它们被设计为操作一组信号量,即信号量集,而不是单个信号量。

semget 系统调用

semget系统调用创建一个新的信号量集,或获取一个已经存在的信号量集。

#include <sys/sem.h>
int semget(key_t key, int num_sems, int sem_flags);
参数

key:键值,用来标志全局唯一的信号量集,要通过信号量通信的进程需要使用相同的键值来创建/获取该信号量。

num_sems:指定要创建/获取的信号量集中信号量的数目,如果是创建信号量,该值必须指定,如果是获取已经存在的信号量,该值可以设置为0。

sem_flags:指定一组标志,低端的9个bite是信号量的权限,格式和含义与openmode参数一致。此外,它可以和IPC_CREAT标志做按位或运算以创建新的信号量集。还可以联合使用IPC_CREATIPC_EXCL标志确保创建新的、唯一的信号量集,如果这时候该信号量集已经存在,semget返回错误并设置errno为EEXIST

返回值

semget成功返回一个正整数,也就是信号量集的标识符,失败返回-1并设置errno。

如果用semget创建一个新的信号量集,与之相关的内核数据结构体semid_ds将被创建并初始化。

struct semid_ds {struct ipc_perm sem_perm;		/* 信号量操作权限 */unsigned long int sem_nsems;	/* 该信号量集中的信号量数目 */time_t sem_otime;				/* 最后一次调用 semop 的时间 */time_t sem_ctime;				/* 最后一次调用 semctl 的时间 *//* 省略其他填充字段 */
}:struct ipc_perm{key_t key;						/* 键值 */uid_t uid;						/* 所有者的用户id */gid_t gid;						/* 所有者的组id */uid_t cuid;						/* 创建者的用户id */git_t cgid;						/* 创建者的组id */mode_t mode;					/* 访问权限 *//* 省略其他填充字段 */
};

semop 系统调用

semop系统调用改变信号量的值,即执行P、V操作,在讨论semop函数前,先介绍与每个信号量关联的一些重要的内核变量:

unsigned short semval; 			/* 信号量的值 */
unsigned short semzcnt; 		/* 等待信号量变为0的进程数量 */
unsigned short semncnt; 		/* 等待信号量值增加的进程数量 */
pid_t sempid; 					/* 最后一次执行 semop 操作的进程ID */

semop函数对信号量的操作实际就是改变上述内核变量的操作,该函数定义如下:

#include <sys/sem.h>
int semop(int sem_id, struct sembuf* sem_ops, size_t num_sem_ops);
参数

sem_idsemget调用返回的信号量集标识符,指定被操作的目标信号量集。

sem_ops:指向一个 sembuf 类型结构体的数组:

struct sembuf
{unsigned short int sem_num;short int sem_op;short int sem_flg;
};
  • sem_num:信号量集中信号量的编号,0代表信号量集的第一个信号量,以此类推。
  • sem_op:指定操作类型,可选值:正整数,0、负整数,同时受到 sem_flg的影响。
  • sem_flg:可选值为IPC_NOWAITSEM_UNDO

num_sem_ops:指定要执行的操作个数,即sem_ops数组中元素的个数。semop函数对sem_ops数组参数中的每个成员按数组顺序依次执行操作,且该过程是原子操作,以避免别的进程在同一时刻按不同顺序对该信号集中的信号量执行semop函数导致的竞态条件。

返回值

semop成功返回0,失败返回-1并设置errno。

semctl 系统调用

semctl系统调用允许调用者对信号量进行直接控制:

#include <sys/sem.h>
int semctl(int sem_id, int sem_num, int command, ...);

特殊键值 IPC_PRIVATE

semget的调用者可以给其key参数传递一个特殊键值IPC_PRIVATE(其值为0),这样无论该信号量是否已存在,semget函数都将创建一个新信号量,使用该键值创建的信号量并非像它的名字声称的那样是进程私有的,其他进程,尤其是子进程,也有方法来访问这个信号量,所以semget函数的man手册的BUGS部分上说,使用名字IPC_PRIVATE有些误导(历史原因),应称为IPC_NEW

共享内存

共享内存是最高效的IPC机制,因为它不涉及进程之间的任何数据传输,这种高效率带来的问题是,我们必须用其他辅助手段来同步进程对共享内存的访问,否则会产生竞态条件,因此,共享内存通常和其他进程间通信方式一起使用。

Linux共享内存的API都定义在sys/shm.h头文件中,包括4个系统调用shmgetshmatshmdtshmctl

shmget 系统调用

shmget 系统调用创建一段新的共享内存,或者获取一段已经存在的共享内存。

#include <sys/shm.h>
int shmget(key_t key, size_t size, int shmflg);

shmget 成功时返回一个正整数值,它是共享内存的标识符;失败时返回-1 并设置 errno。

shmatshmdt 系统调用

共享内存被创建/获取后,我们不能立即访问它,而是需要先将它关联到进程的地址空间中,使用完共享内存后,我们也需要将它从进程地址空间中分离,这两项任务分别由以下两个系统调用实现:

#include <sys/shm.h>
void* shmat(int shm_id, const void* shm_addr, int shmflg);
int shmdt(const void* shm_addr);

shm_id参数是由shmget函数返回的共享内存标识符。shm_addr参数指定将共享内存关联到进程的哪块地址空间,最终效果还受到shmflg参数的可选标志SHM_RND的影响。

shmat函数成功时返回共享内存被关联到的地址,失败则返回(void *)-1并设置errno。

shmdt函数将关联到shm_addr参数处的共享内存从进程中分离,它成功时返回0,失败则返回-1并设置errno。

shmctl 系统调用

shmctl系统调用控制共享内存的某些属性。

#include <sys/shm.h>
int shmctl(int shm_id, int command, struct shmid_ds* buf);

共享内存的 POSIX 方法

mmap函数和munmap函数 利用mmap函数的MAP_ANONYMOUS标志可以实现父、子进程间的匿名内存共享。通过打开同一个文件,mmap也可以实现无关进程之间的内存共享。

Linux提供了另一种在无关进程间共享内存的方式,这种方式无须任何文件的支持,但它需要先用shm_open函数来创建或打开一个POSIX共享内存对象:

#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>int shm_open ( const char* name, int oflag, mode_t mode );

shm_open函数的使用方法与open系统调用完全相同。

shm_open函数成功时返回一个文件描述符,该文件描述符可用于后续的mmap调用,从而将共享内存关联到调用进程。shm_open函数失败时返回-1,并设置errno。

和打开的文件最后需要关闭一样,由shm_open函数创建的共享内存对象用完后也需要删除,可通过shm_unlink函数实现:

#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>int shm_unlink ( const char* name );

shm_unlink函数将name参数指定的共享内存对象标记为等待删除,当所有使用该共享内存对象的进程都使用munmap函数将它从进程中分离后,系统将销毁这个共享内存对象所占据的资源。

如果代码中使用了以上POSIX共享内存函数,则编译时需要指定链接选项-lrt

共享内存实例

暂略。

消息队列

消息队列是在两个进程间传递二进制数据块的方式,每个数据块都有一个特定类型,接收方可以根据类型来有选择地接收数据,而不一定像管道和命名管道那样必须以先进先出的方式接收数据。

Linux消息队列的API都定义在sys/msg.h头文件中,包括4个系统调用:msggetmsgsndmsgrcvmsgctl

msgget 系统调用

msgget系统调用创建一个消息队列,或获取一个已有的消息队列:

#include <sys/msg.h>
int msgget(key_t key, int msgflg);

key参数是一个键值,用来标识一个全局唯一的消息队列。

msgget函数成功时返回一个正整数值,它是消息队列的标识符,失败时返回-1并设置errno。

msgsnd 系统调用

msgsnd系统调用将一条消息添加到消息队列中:

#include <sys/msg.h>
int msgsnd(int msqid, const void* msg_ptr, size_t msg_sz, int msgflg);struct msgbuf{long mtype;//消息类型char mtext[512];//消息数据
};

msqid参数是由msgget函数返回的消息队列标识符。

msg_ptr参数指向一个准备发送的消息,消息被定义为如下类型:

struct msgbuf{long mtype;			/* 消息类型 */char mtext[512];	/* 消息数据 */
};

msgrcv 系统调用

msgrcv系统调用从消息队列中获取消息:

#include <sys/msg.h>
int msgrcv(int msqid, void* msg_ptr, size_t msg_sz, long int msgtype, int msgflg);

msgctl 系统调用

msgctl系统调用控制消息队列的某些属性:

#incldue <sys/msg.h>
int msgctl(int msqid, int command, struct msqid_ds* buf);

IPC 命令

以上3种System V IPC进程间通信方式都使用一个全局唯一的键值来描述一个共享资源,当程序调用semgetshmgetmsgget时,就创建了这些共享资源的一个实例。Linux提供ipcs命令来观察当前系统上拥有哪些共享资源实例:

输出结果分段显示了系统拥有的消息队列、共享内存、信号量资源,可见,该系统目前尚未使用任何消息队列和信号量,但分配了一组键值为0的共享内存。

可用ipcrm命令删除遗留在系统中的共享资源。

实战 10: 在进程间传递文件描述符

fork调用后,父进程中打开的文件描述符在子进程中仍然保持打开,所以文件描述符可以很方便地从父进程传递到子进程。注意,传递一个文件描述符并不是传递一个文件描述符的值,而是在接收进程中创建一个新的文件描述符,且新文件描述符和发送进程中被传递的文件描述符指向内核中相同的文件表项。

要想在两个不相干的进程之间传递文件描述符,在Linux下,可利用UNIX域socket在进程间传递特殊的辅助数据,以实现文件描述符的传递,下例代码中,子进程中打开一个文件描述符,然后将它传递给父进程,父进程则通过读取该文件描述符来获得文件内容:

#include <sys/socket.h>
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <assert.h>
#include <string.h>static const int CONTROL_LEN = CMSG_LEN(sizeof(int));
// 发送文件描述符,fd参数是用来传递信息的UNIX域socket,fd_to_send参数是待发送的文件描述符
void send_fd(int fd, int fd_to_send) {struct iovec iov[1];struct msghdr msg;char buf[0];iov[0].iov_base = buf;iov[0].iov_len = 1;msg.msg_name = NULL;msg.msg_namelen = 0;msg.msg_iov = iov;msg.msg_iovlen = 1;cmsghdr cm;cm.cmsg_len = CONTROL_LEN;cm.cmsg_level = SOL_SOCKET;cm.cmsg_type = SCM_RIGHTS;*(int *)CMSG_DATA(&cm) = fd_to_send;msg.msg_control = &cm;		/* 设置辅助数据 */msg.msg_controllen = CONTROL_LEN;sendmsg(fd, &msg, 0);       /* 通用数据读 */
}// 接收目标文件描述符
int recv_fd(int fd) {struct iovec iov[1];struct msghdr msg;char buf[0];iov[0].iov_base = buf;iov[0].iov_len = 1;msg.msg_name = NULL;msg.msg_namelen = 0;msg.msg_iov = iov;msg.msg_iovlen = 1;cmsghdr cm;msg.msg_control = &cm;msg.msg_controllen = CONTROL_LEN;recvmsg(fd, &msg, 0);       /* 通用数据写 */int fd_to_read = *(int *)CMSG_DATA(&cm);return fd_to_read;
}int main() {int pipefd[2];int fd_to_pass = 0;/* 创建父、子进程间的管道,文件描述符pipefd[0]和pipefd[1]都是UNIX域socket */int ret = socketpair(PF_UNIX, SOCK_DGRAM, 0, pipefd);assert(ret != -1);pid_t pid = fork(); /* 创建子进程 */assert(pid >= 0);if (pid == 0) {close(pipefd[0]);                                       /* 子进程关闭读 */fd_to_pass = open("test.txt", O_RDWR, 0666);            /* 打开文件 */send_fd(pipefd[1], (fd_to_pass > 0) ? fd_to_pass : 0);  /* 子进程通过管道将文件描述符发送到父进程,如果文件打开失败,则子进程将标准输入发送到父进程 */close(fd_to_pass);exit(0);}close(pipefd[1]); /* 父进程关闭写 */fd_to_pass = recv_fd(pipefd[0]);    /* 父进程从管道接收目标文件描述符 */char buf[1024];                     /* 存放数据 */memset(buf, '\0', 1024);// 读目标文件描述符,验证其有效性read(fd_to_pass, buf, 1024);printf("I got fd %d and data %s\n", fd_to_pass, buf);close(fd_to_pass);
}
123

结构体 iovec见:readv函数和writev函数

效果:

参考文章

  1. Linux高性能服务器编程-游双——第十三章 多进程编程_linux高性能服务器编程 pdf-CSDN博客
  2. Linux高性能服务器编程 学习笔记 第十三章 多进程编程_squid和socketpair-CSDN博客

http://www.ppmy.cn/devtools/104736.html

相关文章

this.$nextTick() 是 Vue.js 提供的一个方法

this.$nextTick() 是 Vue.js 提供的一个方法&#xff0c;用于在 DOM 更新完成后执行指定的代码。它的作用主要是确保在 Vue.js 完成 DOM 更新后&#xff0c;再执行某些依赖于更新的操作。这个方法通常用于处理需要在视图更新后立即进行的操作&#xff0c;如获取最新的 DOM 元素…

常用Numpy操作(笔记整理)

目录 一、常用&#xff08;自查&#xff09; 1. 创建数组&#xff08;array&#xff09; 2. 数组形状&#xff08;shape&#xff09; 3. 数组维度&#xff08;ndim&#xff09; 4. 数组⼤⼩&#xff08;size&#xff09; 5. 数组数据类型&#xff08;dtype&#xff09; …

论文速读|ReKep:空间时间理论的关系关键点约束,用于机器人操作

项目地址&#xff1a;ReKep | Spatio-Temporal ReasoningReKep | Spatio-Temporal Reasoning of Relational Keypoint Constraints for Robotic ManipulationReKep | Spatio-Temporal Reasoning ReKep&#xff08;Relational Keypoint Constraints&#xff09;是一种基于视觉的…

微信小程序遇到的问题

wx.redirectTo 跳转闪屏 wx.redirectTo 是让当前页面出栈&#xff0c;在加载新的页面&#xff0c;&#xff0c;&#xff0c;&#xff0c;当我的当前页是这个栈的唯一页面的时候&#xff0c;就会出现闪屏 想要像wx.navigateTo那样有过渡效果&#xff0c;可以先getCurrentPages(…

Web3前端开发:重塑互联网的未来

随着技术的飞速发展&#xff0c;互联网正逐步从Web 2.0向Web3迈进。这一转变不仅带来了技术层面的革新&#xff0c;更在商业模式、用户体验以及数据所有权方面引发了深刻的变革。作为这一变革的前沿阵地&#xff0c;Web3前端开发正成为开发者们探索新领域、实现创新应用的关键。…

代码随想录算法训练营第四十天| 背包问题(二维)、LeetCode416.分割等和子集

#背包问题&#xff08;二维&#xff09; #背包问题&#xff08;二维&#xff09;&#xff1a;带你学透0-1背包问题&#xff01;| 关于背包问题&#xff0c;你不清楚的地方&#xff0c;这里都讲了&#xff01;| 动态规划经典问题 | 数据结构与算法_哔哩哔哩_bilibili 动态规划五…

基于自适应狮群算法优化GRU神经网络进水量预测,gsclst-gru进水量预测,基于黄金正弦改进的狮群算法优化GRU进水量预测

目录 背影 摘要 LSTM的基本定义 LSTM实现的步骤 gru的原理 狮群群算法原理 基于自适应狮群算法优化BILSTM神经网络进水量预测,gsclst-gru进水量预测,基于黄金正弦改进的狮群算法优化BILSTM进水量预测 结果分析 展望 参考论文 背影 传统的方法回归分析容易陷入局部最优准确率…

World of Warcraft [CLASSIC][80][Shushia] [Obsidian Sanctum][Sartharion]

黑曜石圣殿 [Obsidian Sanctum] 萨塔里奥[Sartharion] 号旗披风、龙魂图典、五色巨龙之怒、黑曜石巨盔等装备&#xff0c;都是非常极品的BIS装备 召唤顺序&#xff1a;&#xff08;中&#xff09;塔尼布隆、&#xff08;右&#xff09;沙德隆、&#xff08;左&#xff09;维斯…