CFS的CPU使用率高吗?


25

我问了一个先前的问题,试图找出将应用程序从RHEL 5迁移到RHEL 6时CPU使用率增加的原因。我所做的分析似乎表明,这是由内核中的CFS引起的。我编写了一个测试应用程序来尝试验证是否是这种情况(已删除原始测试应用程序以适合大小限制,但仍在git repo中提供)

我在RHEL 5上使用以下命令对其进行了编译:

cc test_select_work.c -O2 -DSLEEP_TYPE=0 -Wall -Wextra -lm -lpthread -o test_select_work

然后,我使用这些参数,直到在Dell Precision m6500上每次迭代的执行时间约为1 ms。

我在RHEL 5上得到以下结果:

./test_select_work 1000 10000 300 4
time_per_iteration: min: 911.5 us avg: 913.7 us max: 917.1 us stddev: 2.4 us
./test_select_work 1000 10000 300 8
time_per_iteration: min: 1802.6 us avg: 1803.9 us max: 1809.1 us stddev: 2.1 us
./test_select_work 1000 10000 300 40
time_per_iteration: min: 7580.4 us avg: 8567.3 us max: 9022.0 us stddev: 299.6 us

还有RHEL 6上的以下内容:

./test_select_work 1000 10000 300 4
time_per_iteration: min: 914.6 us avg: 975.7 us max: 1034.5 us stddev: 50.0 us
./test_select_work 1000 10000 300 8
time_per_iteration: min: 1683.9 us avg: 1771.8 us max: 1810.8 us stddev: 43.4 us
./test_select_work 1000 10000 300 40
time_per_iteration: min: 7997.1 us avg: 8709.1 us max: 9061.8 us stddev: 310.0 us

在两个版本上,这些结果都与我期望的相对,每次迭代的平均时间相对线性地扩展。然后我重新编译,-DSLEEP_TYPE=1并在RHEL 5上获得以下结果:

./test_select_work 1000 10000 300 4
time_per_iteration: min: 1803.3 us avg: 1902.8 us max: 2001.5 us stddev: 113.8 us
./test_select_work 1000 10000 300 8
time_per_iteration: min: 1997.1 us avg: 2002.0 us max: 2010.8 us stddev: 5.0 us
./test_select_work 1000 10000 300 40
time_per_iteration: min: 6958.4 us avg: 8397.9 us max: 9423.7 us stddev: 619.7 us

并在RHEL 6上获得以下结果:

./test_select_work 1000 10000 300 4
time_per_iteration: min: 2107.1 us avg: 2143.1 us max: 2177.7 us stddev: 30.3 us
./test_select_work 1000 10000 300 8
time_per_iteration: min: 2903.3 us avg: 2903.8 us max: 2904.3 us stddev: 0.3 us
./test_select_work 1000 10000 300 40
time_per_iteration: min: 8877.7.1 us avg: 9016.3 us max: 9112.6 us stddev: 62.9 us

在RHEL 5上,结果达到了我的预期(4个线程由于1 ms睡眠而花费了两倍的时间,但是8个线程花费了相同的时间,因为每个线程现在睡眠了大约一半的时间,并且仍然相当线性增加)。

但是,对于RHEL 6,使用4个线程所花费的时间比预期的加倍增加了大约15%,而使用8线程的情况所花费的时间比预期的轻微增加增加了约45%。4线程情况下的增加似乎是RHEL 6实际上睡眠了1微秒多于1毫秒,而RHEL 5仅睡眠了大约900 us,但这并不能解释8和40意外增加的原因。螺纹盒。

我看到了具有所有3个-DSLEEP_TYPE值的类似行为。我也尝试使用sysctl中的调度程序参数,但是似乎对结果没有重大影响。关于如何进一步诊断此问题的任何想法?

更新时间:2012-05-07

我在/ proc / stat // tasks // stat中添加了对用户和系统CPU使用率的度量,作为测试的输出,以尝试获得另一个观察点。我还发现了在添加外部迭代循环时引入的均值和标准差的更新方式问题,因此,我将添加具有均值和标准差校正值的新图。我已经包括了更新的程序。我还做了一个git repo来跟踪代码,它在这里可用。

#include <limits.h>
#include <math.h>
#include <poll.h>
#include <pthread.h>
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <unistd.h>
#include <sys/select.h>
#include <sys/syscall.h>
#include <sys/time.h>


// Apparently GLIBC doesn't provide a wrapper for this function so provide it here
#ifndef HAS_GETTID
pid_t gettid(void)
{
  return syscall(SYS_gettid);
}
#endif


// The different type of sleep that are supported
enum sleep_type {
  SLEEP_TYPE_NONE,
  SLEEP_TYPE_SELECT,
  SLEEP_TYPE_POLL,
  SLEEP_TYPE_USLEEP,
  SLEEP_TYPE_YIELD,
  SLEEP_TYPE_PTHREAD_COND,
  SLEEP_TYPE_NANOSLEEP,
};

// Information returned by the processing thread
struct thread_res {
  long long clock;
  long long user;
  long long sys;
};

// Function type for doing work with a sleep
typedef struct thread_res *(*work_func)(const int pid, const int sleep_time, const int num_iterations, const int work_size);

// Information passed to the thread
struct thread_info {
  pid_t pid;
  int sleep_time;
  int num_iterations;
  int work_size;
  work_func func;
};


inline void get_thread_times(pid_t pid, pid_t tid, unsigned long long *utime, unsigned long long *stime)
{
  char filename[FILENAME_MAX];
  FILE *f;

  sprintf(filename, "/proc/%d/task/%d/stat", pid, tid);
  f = fopen(filename, "r");
  if (f == NULL) {
    *utime = 0;
    *stime = 0;
    return;
  }

  fscanf(f, "%*d %*s %*c %*d %*d %*d %*d %*d %*u %*u %*u %*u %*u %Lu %Lu", utime, stime);

  fclose(f);
}

// In order to make SLEEP_TYPE a run-time parameter function pointers are used.
// The function pointer could have been to the sleep function being used, but
// then that would mean an extra function call inside of the "work loop" and I
// wanted to keep the measurements as tight as possible and the extra work being
// done to be as small/controlled as possible so instead the work is declared as
// a seriees of macros that are called in all of the sleep functions. The code
// is a bit uglier this way, but I believe it results in a more accurate test.

// Fill in a buffer with random numbers (taken from latt.c by Jens Axboe <jens.axboe@oracle.com>)
#define DECLARE_FUNC(NAME) struct thread_res *do_work_##NAME(const int pid, const int sleep_time, const int num_iterations, const int work_size)

#define DECLARE_WORK() \
  int *buf; \
  int pseed; \
  int inum, bnum; \
  pid_t tid; \
  struct timeval clock_before, clock_after; \
  unsigned long long user_before, user_after; \
  unsigned long long sys_before, sys_after; \
  struct thread_res *diff; \
  tid = gettid(); \
  buf = malloc(work_size * sizeof(*buf)); \
  diff = malloc(sizeof(*diff)); \
  get_thread_times(pid, tid, &user_before, &sys_before); \
  gettimeofday(&clock_before, NULL)

#define DO_WORK(SLEEP_FUNC) \
  for (inum=0; inum<num_iterations; ++inum) { \
    SLEEP_FUNC \
     \
    pseed = 1; \
    for (bnum=0; bnum<work_size; ++bnum) { \
      pseed = pseed * 1103515245 + 12345; \
      buf[bnum] = (pseed / 65536) % 32768; \
    } \
  } \

#define FINISH_WORK() \
  gettimeofday(&clock_after, NULL); \
  get_thread_times(pid, tid, &user_after, &sys_after); \
  diff->clock = 1000000LL * (clock_after.tv_sec - clock_before.tv_sec); \
  diff->clock += clock_after.tv_usec - clock_before.tv_usec; \
  diff->user = user_after - user_before; \
  diff->sys = sys_after - sys_before; \
  free(buf); \
  return diff

DECLARE_FUNC(nosleep)

{
  DECLARE_WORK();

  // Let the compiler know that sleep_time isn't used in this function
  (void)sleep_time;

  DO_WORK();

  FINISH_WORK();
}

DECLARE_FUNC(select)
{
  struct timeval ts;
  DECLARE_WORK();

  DO_WORK(
    ts.tv_sec = 0;
    ts.tv_usec = sleep_time;
    select(0, 0, 0, 0, &ts);
    );

  FINISH_WORK();
}

DECLARE_FUNC(poll)
{
  struct pollfd pfd;
  const int sleep_time_ms = sleep_time / 1000;
  DECLARE_WORK();

  pfd.fd = 0;
  pfd.events = 0;

  DO_WORK(
    poll(&pfd, 1, sleep_time_ms);
    );

  FINISH_WORK();
}

DECLARE_FUNC(usleep)
{
  DECLARE_WORK();

  DO_WORK(
    usleep(sleep_time);
    );

  FINISH_WORK();
}

DECLARE_FUNC(yield)
{
  DECLARE_WORK();

  // Let the compiler know that sleep_time isn't used in this function
  (void)sleep_time;

  DO_WORK(
    sched_yield();
    );

  FINISH_WORK();
}

DECLARE_FUNC(pthread_cond)
{
  pthread_cond_t cond  = PTHREAD_COND_INITIALIZER;
  pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
  struct timespec ts;
  const int sleep_time_ns = sleep_time * 1000;
  DECLARE_WORK();

  pthread_mutex_lock(&mutex);

  DO_WORK(
    clock_gettime(CLOCK_REALTIME, &ts);
    ts.tv_nsec += sleep_time_ns;
    if (ts.tv_nsec >= 1000000000) {
      ts.tv_sec += 1;
      ts.tv_nsec -= 1000000000;
    }
    pthread_cond_timedwait(&cond, &mutex, &ts);
    );

  pthread_mutex_unlock(&mutex);

  pthread_cond_destroy(&cond);
  pthread_mutex_destroy(&mutex);

  FINISH_WORK();
}

DECLARE_FUNC(nanosleep)
{
  struct timespec req, rem;
  const int sleep_time_ns = sleep_time * 1000;
  DECLARE_WORK();

  DO_WORK(
    req.tv_sec = 0;
    req.tv_nsec = sleep_time_ns;
    nanosleep(&req, &rem);
    );

  FINISH_WORK();
}

void *do_test(void *arg)
{
  const struct thread_info *tinfo = (struct thread_info *)arg;

  // Call the function to do the work
  return (*tinfo->func)(tinfo->pid, tinfo->sleep_time, tinfo->num_iterations, tinfo->work_size);
}

struct thread_res_stats {
  double min;
  double max;
  double avg;
  double stddev;
  double prev_avg;
};

#ifdef LLONG_MAX
  #define THREAD_RES_STATS_INITIALIZER {LLONG_MAX, LLONG_MIN, 0, 0, 0}
#else
  #define THREAD_RES_STATS_INITIALIZER {LONG_MAX, LONG_MIN, 0, 0, 0}
#endif

void update_stats(struct thread_res_stats *stats, long long value, int num_samples, int num_iterations, double scale_to_usecs)
{
  // Calculate the average time per iteration
  double value_per_iteration = value * scale_to_usecs / num_iterations;

  // Update the max and min
  if (value_per_iteration < stats->min)
    stats->min = value_per_iteration;
  if (value_per_iteration > stats->max)
    stats->max = value_per_iteration;
  // Update the average
  stats->avg += (value_per_iteration - stats->avg) / (double)(num_samples);
  // Update the standard deviation
  stats->stddev += (value_per_iteration - stats->prev_avg) * (value_per_iteration - stats->avg);
  // And record the current average for use in the next update
  stats->prev_avg= stats->avg;
}

void print_stats(const char *name, const struct thread_res_stats *stats)
{
  printf("%s: min: %.1f us avg: %.1f us max: %.1f us stddev: %.1f us\n",
      name,
      stats->min,
      stats->avg,
      stats->max,
      stats->stddev);
}

int main(int argc, char **argv)
{
  if (argc <= 6) {
    printf("Usage: %s <sleep_time> <outer_iterations> <inner_iterations> <work_size> <num_threads> <sleep_type>\n", argv[0]);
    printf("  outer_iterations: Number of iterations for each thread (used to calculate statistics)\n");
    printf("  inner_iterations: Number of work/sleep cycles performed in each thread (used to improve consistency/observability))\n");
    printf("  work_size: Number of array elements (in kb) that are filled with psuedo-random numbers\n");
    printf("  num_threads: Number of threads to spawn and perform work/sleep cycles in\n");
    printf("  sleep_type: 0=none 1=select 2=poll 3=usleep 4=yield 5=pthread_cond 6=nanosleep\n");
    return -1;
  }

  struct thread_info tinfo;
  int outer_iterations;
  int sleep_type;
  int s, inum, tnum, num_samples, num_threads;
  pthread_attr_t attr;
  pthread_t *threads;
  struct thread_res *res;
  struct thread_res **times;
  // Track the stats for each of the measurements
  struct thread_res_stats stats_clock = THREAD_RES_STATS_INITIALIZER;
  struct thread_res_stats stats_user = THREAD_RES_STATS_INITIALIZER;
  struct thread_res_stats stats_sys = THREAD_RES_STATS_INITIALIZER;
  // Calculate the conversion factor from clock_t to seconds
  const long clocks_per_sec = sysconf(_SC_CLK_TCK);
  const double clocks_to_usec = 1000000 / (double)clocks_per_sec;

  // Get the parameters
  tinfo.pid = getpid();
  tinfo.sleep_time = atoi(argv[1]);
  outer_iterations = atoi(argv[2]);
  tinfo.num_iterations = atoi(argv[3]);
  tinfo.work_size = atoi(argv[4]) * 1024;
  num_threads = atoi(argv[5]);
  sleep_type = atoi(argv[6]);
  switch (sleep_type) {
    case SLEEP_TYPE_NONE:   tinfo.func = &do_work_nosleep; break;
    case SLEEP_TYPE_SELECT: tinfo.func = &do_work_select;  break;
    case SLEEP_TYPE_POLL:   tinfo.func = &do_work_poll;    break;
    case SLEEP_TYPE_USLEEP: tinfo.func = &do_work_usleep;  break;
    case SLEEP_TYPE_YIELD:  tinfo.func = &do_work_yield;   break;
    case SLEEP_TYPE_PTHREAD_COND:  tinfo.func = &do_work_pthread_cond;   break;
    case SLEEP_TYPE_NANOSLEEP:  tinfo.func = &do_work_nanosleep;   break;
    default:
      printf("Invalid sleep type: %d\n", sleep_type);
      return -7;
  }

  // Initialize the thread creation attributes
  s = pthread_attr_init(&attr);
  if (s != 0) {
    printf("Error initializing thread attributes\n");
    return -2;
  }

  // Allocate the memory to track the threads
  threads = calloc(num_threads, sizeof(*threads));
  times = calloc(num_threads, sizeof(*times));
  if (threads == NULL) {
    printf("Error allocating memory to track threads\n");
    return -3;
  }

  // Initialize the number of samples
  num_samples = 0;
  // Perform the requested number of outer iterations
  for (inum=0; inum<outer_iterations; ++inum) {
    // Start all of the threads
    for (tnum=0; tnum<num_threads; ++tnum) {
      s = pthread_create(&threads[tnum], &attr, &do_test, &tinfo);

      if (s != 0) {
        printf("Error starting thread\n");
        return -4;
      }
    }

    // Wait for all the threads to finish
    for (tnum=0; tnum<num_threads; ++tnum) {
      s = pthread_join(threads[tnum], (void **)(&res));
      if (s != 0) {
        printf("Error waiting for thread\n");
        return -6;
      }

      // Save the result for processing when they're all done
      times[tnum] = res;
    }

    // For each of the threads
    for (tnum=0; tnum<num_threads; ++tnum) {
      // Increment the number of samples in the statistics
      ++num_samples;
      // Update the statistics with this measurement
      update_stats(&stats_clock, times[tnum]->clock, num_samples, tinfo.num_iterations, 1);
      update_stats(&stats_user, times[tnum]->user, num_samples, tinfo.num_iterations, clocks_to_usec);
      update_stats(&stats_sys, times[tnum]->sys, num_samples, tinfo.num_iterations, clocks_to_usec);
      // And clean it up
      free(times[tnum]);
    }
  }

  // Clean up the thread creation attributes
  s = pthread_attr_destroy(&attr);
  if (s != 0) {
    printf("Error cleaning up thread attributes\n");
    return -5;
  }

  // Finish the calculation of the standard deviation
  stats_clock.stddev = sqrtf(stats_clock.stddev / (num_samples - 1));
  stats_user.stddev = sqrtf(stats_user.stddev / (num_samples - 1));
  stats_sys.stddev = sqrtf(stats_sys.stddev / (num_samples - 1));

  // Print out the statistics of the times
  print_stats("gettimeofday_per_iteration", &stats_clock);
  print_stats("utime_per_iteration", &stats_user);
  print_stats("stime_per_iteration", &stats_sys);

  // Clean up the allocated threads and times
  free(threads);
  free(times);

  return 0;
}

我在具有几种不同操作系统版本的Dell Vostro 200(双核CPU)上重新运行了测试。我意识到其中的一些将应用不同的补丁程序,并且不会是“纯内核代码”,但这是我可以在不同版本的内核上运行测试并进行比较的最简单方法。我使用gnuplot生成了图,并包含了来自bugzilla的有关此问题的版本。

所有这些测试都是通过带有以下脚本和此命令的以下命令运行的./run_test 1000 10 1000 250 8 6 <os_name>

#!/bin/bash

if [ $# -ne 7 ]; then
  echo "Usage: `basename $0` <sleep_time> <outer_iterations> <inner_iterations> <work_size> <max_num_threads> <max_sleep_type> <test_name>"
  echo "  max_num_threads: The highest value used for num_threads in the results"
  echo "  max_sleep_type: The highest value used for sleep_type in the results"
  echo "  test_name: The name of the directory where the results will be stored"
  exit -1
fi

sleep_time=$1
outer_iterations=$2
inner_iterations=$3
work_size=$4
max_num_threads=$5
max_sleep_type=$6
test_name=$7

# Make sure this results directory doesn't already exist
if [ -e $test_name ]; then
  echo "$test_name already exists";
  exit -1;
fi
# Create the directory to put the results in
mkdir $test_name
# Run through the requested number of SLEEP_TYPE values
for i in $(seq 0 $max_sleep_type)
do
  # Run through the requested number of threads
  for j in $(seq 1 $max_num_threads)
  do
    # Print which settings are about to be run
    echo "sleep_type: $i num_threads: $j"
    # Run the test and save it to the results file
    ./test_sleep $sleep_time $outer_iterations $inner_iterations $work_size $j $i >> "$test_name/results_$i.txt"
  done
done

这是我观察到的摘要。这次我将成对比较它们,因为我认为这样可以提供更多信息。

CentOS 5.6与CentOS 6.2

CentOS 5.6上的每次迭代的挂钟时间(gettimeofday)比6.2多得多,但这是有道理的,因为CFS应该做得更好,使进程具有相等的CPU时间,从而获得更一致的结果。同样非常清楚的是,CentOS 6.2在使用不同的睡眠机制进行睡眠时所花费的时间更加准确和一致。 gettimeofday CentOS 5.6 gettimeofday CentOS 6.2

“惩罚”在6.2中的线程数很少(在gettimeofday和用户时间图上可见)绝对明显,但在较高数量的线程中似乎有所减少(用户时间的差异可能只是一个考虑因素,因为用户时间测量就是这样)。

utime CentOS 5.6 utime CentOS 6.2

系统时间图显示,6.2中的睡眠机制比5.6中的睡眠机制消耗的系统更多,这与以前对50个进程进行简单测试的结果相对应,后者仅在6.2而不是5.6上调用select消耗了少量的CPU。 。

stime CentOS 5.6 stime CentOS 6.2

我认为值得一提的是,使用sched_yield()不会产生与sleep方法相同的代价。由此得出的结论是,问题的根源不是调度程序本身,而是问题的睡眠方法与调度程序的交互。

Ubuntu 7.10和Ubuntu 8.04-4

两者之间的内核版本差异小于CentOS 5.6和6.2,但它们仍然跨越引入CFS的时间段。第一个有趣的结果是,选择和轮询似乎是在8.04上具有“惩罚性”的唯一睡眠机制,并且惩罚继续比CentOS 6.2看到的线程数更多。

gettimeofday Ubuntu 7.10 gettimeofday Ubuntu 8.04-4

select和poll和Ubuntu 7.10的用户时间过低,因此这似乎是当时存在的某种会计问题,但我认为与当前问题/讨论无关。

utime Ubuntu 7.10 utime Ubuntu 8.04-4

与Ubuntu 7.10相比,Ubuntu 8.04的系统时间似乎确实更高,但是这一差异远没有CentOS 5.6与6.2所见的明显不同。

stime Ubuntu 7.10 stime Ubuntu 8.04-4

关于Ubuntu 11.10和Ubuntu 12.04的说明

这里首先要注意的是,Ubuntu 12.04的图与11.10的图可比,因此它们的显示不会防止不必要的冗余。

总体而言,Ubuntu 11.10的绘图显示出与CentOS 6.2所观察到的趋势相同的趋势(这表明这通常是内核问题,而不仅仅是RHEL问题)。一个例外是,Ubuntu 11.10的系统时间似乎比CentOS 6.2的系统时间要高一些,但是再一次,此度量的分辨率非常合适,因此我认为除“它似乎要高一点”以外的任何结论都可以。 ”会踩到薄冰。

带有BFS的Ubuntu 11.10与Ubuntu 11.10

可以在https://launchpad.net/~chogydan/+archive/ppa中找到将BFS与Ubuntu内核一起使用的PPA,并已安装了该PPA 以生成此比较。我找不到使用BFS运行CentOS 6.2的简单方法,因此我进行了此比较,并且由于Ubuntu 11.10的结果与CentOS 6.2的比较非常好,我相信这是一个公平而有意义的比较。

gettimeofday Ubuntu 11.10 带有BFS的Ubuntu 11.10的gettimeofday

要注意的主要一点是,对于BFS,仅选择和nanosleep会在线程数较少时导致“惩罚”,但似乎会导致与CFS所见的类似的“惩罚”(如果不是更大的话)。线程数。

utime Ubuntu 11.10 带BFS的utime Ubuntu 11.10

另一个有趣的点是,使用BFS的系统时间似乎比使用CFS的系统时间短。再次,由于数据的粗糙性,这开始步履蹒跚,但是似乎确实存在一些差异,并且此结果与简单的50进程选择循环测试相匹配,与使用CFS相比,BFS的CPU使用率更低。

stime Ubuntu 11.10 使用BFS的stime Ubuntu 11.10

我从这两点得出的结论是,BFS不能解决问题,但至少似乎减轻了它在某些领域的影响。

结论

如前所述,我不认为这是调度程序本身的问题,而是睡眠机制与调度程序之间的相互作用。我认为这种增加的CPU使用率应该处于休眠状态,并且几乎不使用CPU,甚至不使用CPU,这是CentOS 5.6的退步,对于希望使用事件循环或机制轮询方式的任何程序来说,这都是一个主要障碍。

我还能获得其他数据或进行测试以帮助进一步诊断问题吗?

2012年6月29日更新

我稍微简化了测试程序,可以在这里找到(帖子开始超出长度限制,因此必须移动它)。


3
哇,详尽的分析-但是有了这么多数据,原来的问题对我来说变得越来越模糊。您可以简化一下吗?1)单个测试2)单个发行版3)两个不同的内核4)15%的速度下降?如果您在上一段中的假设是正确的,那么该是时候开始区分内核资源了,但是觉得其他变量应该首先被消除。
ckhan'5

我添加了测试应用程序的一些输出,现在成对进行比较,以尝试使所有信息的消化更加容易。
戴夫·约翰森

我试图看一下该Bugzilla,但Redhat表示这是“内部Bugzilla,对公众不可见”。对此有任何更新吗?

我是整个RedHat Bug的新手,所以在创建可以做到这一点的Bug时,可能是我做过(或没有做过),但是到目前为止,我所听到的唯一更新是对参数,使其在超线程处理器中表现更好,但尚未真正修复。
戴夫·约翰森

2
CFS是完全公平的调度程序吗?这听起来很有趣-我在SLES11 SP2上也遇到了基于Java的应用程序的性能问题。(与SP1的不同之处)是CFS的变化...
Nils 2012年

Answers:


1

根据SLES 11 SP2发行说明,这可能是对CFS实现方式的一种更改。

由于SLES 11 SP2是当前的SLES版本,因此此行为仍然有效(对于所有3.x内核而言)。

进行此更改是有意的,但可能会有“不良”的副作用。也许所描述的解决方法之一对您有所帮助...


似乎链接有问题,正确的链接在这里,但是我将尝试这些变通办法,看看它们是否对性能有所帮助。
戴夫·约翰森

还有其他消息吗?
vonbrand

@vonbrand您可能不得不问Dave ...
Nils
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.